From 225beea1bdf038b3a200b481e941012eaf6f8950 Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Fri, 14 Aug 2026 17:05:58 +0200 Subject: [PATCH 01/38] REST API endpoints for sites --- src/wp-includes/rest-api.php | 6 + .../class-wp-rest-sites-controller.php | 1278 +++++++++++++++++ .../fields/class-wp-rest-site-meta-fields.php | 54 + src/wp-settings.php | 2 + .../tests/rest-api/rest-sites-controller.php | 872 +++++++++++ 5 files changed, 2212 insertions(+) create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php create mode 100644 src/wp-includes/rest-api/fields/class-wp-rest-site-meta-fields.php create mode 100644 tests/phpunit/tests/rest-api/rest-sites-controller.php diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index d54cee18c5b39..ee8de238a423f 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -324,6 +324,12 @@ function create_initial_rest_routes() { $controller = new WP_REST_Comments_Controller(); $controller->register_routes(); + // Sites. + if ( is_multisite() ) { + $controller = new WP_REST_Sites_Controller(); + $controller->register_routes(); + } + $search_handlers = array( new WP_REST_Post_Search_Handler(), new WP_REST_Term_Search_Handler(), 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 new file mode 100644 index 0000000000000..9d3b6b6c7cf4f --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php @@ -0,0 +1,1278 @@ +namespace = 'wp/v2'; + $this->rest_base = 'sites'; + + $this->meta = new WP_REST_Site_Meta_Fields(); + } + + /** + * Registers the routes for the objects of the controller. + * + * @since 7.2.0 + */ + public function register_routes() { + + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + '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 ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + // Title and administrator reach 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'] ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + 'args' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the object.' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_item' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + 'args' => $update_args, + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + 'args' => array( + 'force' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Required to be true, as sites do not support trashing.' ), + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to read sites. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|bool True if the request has read access, error object otherwise. + */ + public function get_items_permissions_check( $request ) { + + if ( 0 === get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + if ( current_user_can( 'manage_sites' ) ) { + return true; + } + + // Without that capability a user may still ask for their own sites. + if ( $this->is_own_user_filter( $request ) ) { + return true; + } + + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + /** + * Checks whether the request is limited to the sites of the current user. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return bool Whether the request asks for the current user's own sites. + */ + protected function is_own_user_filter( $request ) { + $user = $request['user']; + + if ( empty( $user ) ) { + return false; + } + + if ( 'me' === $user ) { + return true; + } + + return get_current_user_id() === (int) $user; + } + + /** + * Retrieves a list of site items. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, or error object on failure. + */ + public function get_items( $request ) { + + // Retrieve the list of registered collection query parameters. + $registered = $this->get_collection_params(); + + /* + * This array defines mappings between public API query parameters whose + * values are accepted as-passed, and their internal WP_Query parameter + * name equivalents (some are the same). Only values which are also + * present in $registered will be set. + */ + $parameter_mappings = array( + 'domain' => 'domain__in', + 'domain_exclude' => 'domain__not_in', + 'exclude' => 'site__not_in', + 'include' => 'site__in', + 'offset' => 'offset', + 'order' => 'order', + 'network' => 'network__in', + 'network_exclude' => 'network__not_in', + 'per_page' => 'number', + 'path' => 'path__in', + 'path_exclude' => 'path__not_in', + 'search' => 'search', + 'public' => 'public', + 'archived' => 'archived', + 'mature' => 'mature', + 'spam' => 'spam', + 'deleted' => 'deleted', + 'lang_id' => 'lang__in', + 'lang_id_exclude' => 'lang__not_in', + ); + + $prepared_args = array(); + + /* + * For each known parameter which is both registered and present in the request, + * set the parameter's value on the query $prepared_args. + */ + foreach ( $parameter_mappings as $api_param => $wp_param ) { + if ( isset( $registered[ $api_param ], $request[ $api_param ] ) ) { + $prepared_args[ $wp_param ] = $request[ $api_param ]; + } + } + + // WP_Site_Query tests the status columns with is_numeric(), and a boolean is not numeric. + foreach ( array( 'public', 'archived', 'mature', 'spam', 'deleted' ) as $status_param ) { + if ( isset( $prepared_args[ $status_param ] ) ) { + $prepared_args[ $status_param ] = (int) rest_sanitize_boolean( $prepared_args[ $status_param ] ); + } + } + + $user = $request['user']; + + if ( ! empty( $user ) ) { + $user_id = ( 'me' === $user ) ? get_current_user_id() : (int) $user; + $site_ids = $this->get_user_site_ids( $user_id ); + + if ( ! empty( $prepared_args['site__in'] ) ) { + $site_ids = array_intersect( $prepared_args['site__in'], $site_ids ); + } + + // An empty site__in is no restriction at all, so ask for an impossible ID instead. + $prepared_args['site__in'] = $site_ids ? array_values( $site_ids ) : array( 0 ); + } + + if ( isset( $registered['orderby'] ) ) { + $orderby = $request['orderby']; + + // Ordering by an ID list needs a list to order by. + if ( in_array( $orderby, array( 'site__in', 'network__in' ), true ) && empty( $prepared_args[ $orderby ] ) ) { + $orderby = 'id'; + } + + $prepared_args['orderby'] = $orderby; + } + + $prepared_args['no_found_rows'] = false; + + $prepared_args['date_query'] = array(); + + /* + * WP_Date_Query reads the registered column as local time, but wp_blogs + * stores GMT, so the boundaries are converted before they are handed over. + */ + foreach ( array( 'before', 'after' ) as $date_param ) { + if ( ! isset( $registered[ $date_param ], $request[ $date_param ] ) ) { + continue; + } + + $timestamp = rest_parse_date( $request[ $date_param ] ); + + if ( false === $timestamp ) { + continue; + } + + $prepared_args['date_query'][0][ $date_param ] = gmdate( 'Y-m-d H:i:s', $timestamp ); + } + + if ( isset( $registered['page'] ) && empty( $request['offset'] ) ) { + $prepared_args['offset'] = $prepared_args['number'] * ( absint( $request['page'] ) - 1 ); + } + + /** + * Filters arguments, before passing to WP_Site_Query, when querying sites via the REST API. + * + * @since 7.2.0 + * + * @link https://developer.wordpress.org/reference/classes/wp_site_query/ + * @param array $prepared_args Array of arguments for WP_Site_Query. + * @param WP_REST_Request $request The current request. + */ + $prepared_args = apply_filters( 'rest_site_query', $prepared_args, $request ); + + $is_head_request = $request->is_method( 'HEAD' ); + + if ( $is_head_request ) { + // The body stays empty, so the rows are not needed. + $prepared_args['fields'] = 'ids'; + } + + $query = new WP_Site_Query(); + $query_result = $query->query( $prepared_args ); + + $sites = array(); + + if ( ! $is_head_request ) { + foreach ( $query_result as $site ) { + $data = $this->prepare_item_for_response( $site, $request ); + $sites[] = $this->prepare_response_for_collection( $data ); + } + } + + $total_sites = $query->found_sites; + $max_pages = $query->max_num_pages; + + if ( $total_sites < 1 ) { + // Out-of-bounds, run the query again without LIMIT for total count. + unset( $prepared_args['number'], $prepared_args['offset'] ); + + $query = new WP_Site_Query(); + $prepared_args['count'] = true; + + $total_sites = $query->query( $prepared_args ); + $max_pages = (int) ceil( $total_sites / $request['per_page'] ); + } + + $response = $is_head_request ? new WP_REST_Response( array() ) : rest_ensure_response( $sites ); + $response->header( 'X-WP-Total', (string) $total_sites ); + $response->header( 'X-WP-TotalPages', (string) $max_pages ); + + $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) ); + + if ( $request['page'] > 1 ) { + $prev_page = $request['page'] - 1; + + if ( $prev_page > $max_pages ) { + $prev_page = $max_pages; + } + + $prev_link = add_query_arg( 'page', $prev_page, $base ); + $response->link_header( 'prev', $prev_link ); + } + + if ( $max_pages > $request['page'] ) { + $next_page = $request['page'] + 1; + $next_link = add_query_arg( 'page', $next_page, $base ); + + $response->link_header( 'next', $next_link ); + } + + return $response; + } + + /** + * Get the site, if the ID is valid. + * + * @since 7.2.0 + * + * @param int $id Supplied ID. + * @return WP_Site|WP_Error Site object if ID is valid, WP_Error otherwise. + */ + protected function get_site( $id ) { + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + $error = new WP_Error( 'rest_site_invalid_id', __( 'Invalid site ID.' ), array( 'status' => 404 ) ); + if ( (int) $id <= 0 ) { + return $error; + } + + $id = (int) $id; + $site = get_site( $id ); + if ( empty( $site ) ) { + return $error; + } + + return $site; + } + + /** + * Retrieves the IDs of the sites a user is a member of. + * + * @since 7.2.0 + * + * @param int|string $user_id User ID. + * @return int[] Site IDs, empty when the user has none. + */ + public function get_user_site_ids( $user_id ) { + if ( ! is_numeric( $user_id ) || (int) $user_id <= 0 ) { + return array(); + } + + return array_map( 'intval', array_keys( get_blogs_of_user( (int) $user_id ) ) ); + } + + /** + * Checks if a given request has access to read the site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|bool True if the request has read access for the item, error object otherwise. + */ + public function get_item_permissions_check( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + if ( 0 === get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'manage_sites' ) ) { + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + return true; + } + + /** + * Retrieves a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, or error object on failure. + */ + public function get_item( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + $data = $this->prepare_item_for_response( $site, $request ); + $response = rest_ensure_response( $data ); + + return $response; + } + + /** + * Checks if a given request has access to create a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|bool True if the request has access to create items, error object otherwise. + */ + public function create_item_permissions_check( $request ) { + if ( 0 === get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + return current_user_can( 'create_sites' ); + } + + /** + * Creates a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, or error object on failure. + */ + public function create_item( $request ) { + + if ( ! empty( $request['id'] ) ) { + return new WP_Error( 'rest_site_exists', __( 'Cannot create existing site.' ), array( 'status' => 400 ) ); + } + + $prepared_site = $this->prepare_item_for_database( $request ); + if ( is_wp_error( $prepared_site ) ) { + return $prepared_site; + } + + /** + * Filters a site before it is inserted via the REST API. + * + * Allows modification of the site right before it is inserted via wp_insert_site(). + * Returning a WP_Error value from the filter will shortcircuit insertion and allow + * skipping further processing. + * + * @since 7.2.0 + * + * @param array|WP_Error $prepared_site The prepared site data for wp_insert_site(). + * @param WP_REST_Request $request Request used to insert the site. + */ + $prepared_site = apply_filters( 'rest_pre_insert_site', $prepared_site, $request ); + if ( is_wp_error( $prepared_site ) ) { + return $prepared_site; + } + + $site_id = wp_insert_site( $prepared_site ); + + if ( is_wp_error( $site_id ) ) { + $site_id->add_data( array( 'status' => 500 ) ); + + return $site_id; + } + + if ( ! $site_id ) { + return new WP_Error( 'rest_site_failed_create', __( 'Creating site failed.' ), array( 'status' => 500 ) ); + } + + $site = get_site( $site_id ); + + /** + * Fires after a site is created or updated via the REST API. + * + * @since 7.2.0 + * + * @param WP_Site $site Inserted or updated site object. + * @param WP_REST_Request $request Request object. + * @param bool $creating True when creating a site, false + * when updating. + */ + do_action( 'rest_insert_site', $site, $request, true ); + + $schema = $this->get_item_schema(); + + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], $site_id ); + + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $site, $request ); + + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + + $context = current_user_can( 'manage_sites' ) ? 'edit' : 'view'; + + $request->set_param( 'context', $context ); + + $response = $this->prepare_item_for_response( $site, $request ); + $response = rest_ensure_response( $response ); + + $response->set_status( 201 ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $site_id ) ) ); + + return $response; + } + + /** + * Checks if a given REST request has access to update a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|bool True if the request has access to update the item, error object otherwise. + */ + public function update_item_permissions_check( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + if ( 0 === get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + if ( ! $this->check_edit_permission( $site ) ) { + return new WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to edit this site.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + return true; + } + + /** + * Updates a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, or error object on failure. + */ + public function update_item( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + $id = (int) $site->blog_id; + + $prepared_args = $this->prepare_item_for_database( $request ); + + if ( is_wp_error( $prepared_args ) ) { + return $prepared_args; + } + + if ( ! empty( $prepared_args ) ) { + $result = wp_update_site( $id, $prepared_args ); + if ( is_wp_error( $result ) ) { + $result->add_data( array( 'status' => 500 ) ); + + return $result; + } + } + + $site = get_site( $id ); + + /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php */ + do_action( 'rest_insert_site', $site, $request, false ); + + $schema = $this->get_item_schema(); + + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + if ( function_exists( 'is_site_meta_supported' ) && ! is_site_meta_supported() ) { + + return new WP_Error( + 'reset_site_meta_not_supported', + /* translators: %s: database table name */ + sprintf( __( 'The %s table is not installed. Please run the network database upgrade.' ), $GLOBALS['wpdb']->blogmeta ), + array( 'status' => 500 ) + ); + } + + $meta_update = $this->meta->update_value( $request['meta'], $id ); + + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $site, $request ); + + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + + $request->set_param( 'context', 'edit' ); + + $response = $this->prepare_item_for_response( $site, $request ); + + return rest_ensure_response( $response ); + } + + /** + * Checks if a given request has access to delete a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|bool True if the request has access to delete the item, error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + if ( 0 === (int) get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + } + + if ( ! current_user_can( 'delete_sites' ) ) { + return new WP_Error( 'rest_cannot_delete', __( 'Sorry, you are not allowed to delete this site.' ), array( 'status' => rest_authorization_required_code() ) ); + } + + if ( get_main_site_id( (int) $site->site_id ) === (int) $site->blog_id ) { + return new WP_Error( 'rest_cannot_delete_main_site', __( 'Sorry, the main site of a network cannot be deleted.' ), array( 'status' => 403 ) ); + } + + return true; + } + + /** + * Deletes a site. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Error|WP_REST_Response Response object on success, or error object on failure. + */ + public function delete_item( $request ) { + $site = $this->get_site( $request['id'] ); + if ( is_wp_error( $site ) ) { + return $site; + } + + if ( ! (bool) $request['force'] ) { + return new WP_Error( + 'rest_trash_not_supported', + /* translators: %s: force=true */ + sprintf( __( "Sites do not support trashing. Set '%s' to delete." ), 'force=true' ), + array( 'status' => 501 ) + ); + } + + $request->set_param( 'context', 'edit' ); + + $previous = $this->prepare_item_for_response( $site, $request ); + $result = wp_delete_site( $request['id'] ); + + $response = new WP_REST_Response(); + $response->set_data( + array( + 'deleted' => true, + 'previous' => $previous->get_data(), + ) + ); + + if ( is_wp_error( $result ) ) { + $result->add_data( array( 'status' => 500 ) ); + + return $result; + } + + /** + * Fires after a site is deleted via the REST API. + * + * @since 7.2.0 + * + * @param WP_Site $site The deleted site data. + * @param WP_REST_Response $response The response returned from the API. + * @param WP_REST_Request $request The request sent to the API. + */ + do_action( 'rest_delete_site', $site, $response, $request ); + + return $response; + } + + /** + * Prepares a single site output for response. + * + * @since 7.2.0 + * + * @param WP_Site $site Site object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $site, $request ) { + // A HEAD response carries no body, so nothing needs to be prepared. + if ( $request->is_method( 'HEAD' ) ) { + /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php */ + return apply_filters( 'rest_prepare_site', new WP_REST_Response( array() ), $site, $request ); + } + + $data = array( + 'id' => (int) $site->blog_id, + 'network' => (int) $site->site_id, + 'domain' => $site->domain, + 'path' => $site->path, + 'registered' => $this->prepare_date_response( $site->registered ), + 'registered_gmt' => $this->prepare_date_response( $site->registered, true ), + 'last_updated' => $this->prepare_date_response( $site->last_updated ), + 'last_updated_gmt' => $this->prepare_date_response( $site->last_updated, true ), + 'public' => (bool) $site->public, + 'archived' => (bool) $site->archived, + 'mature' => (bool) $site->mature, + 'spam' => (bool) $site->spam, + 'deleted' => (bool) $site->deleted, + 'lang_id' => (int) $site->lang_id, + ); + + $fields = $this->get_fields_for_response( $request ); + + // These four 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; + } + + if ( rest_is_field_included( 'home', $fields ) ) { + $data['home'] = $site->home; + } + + if ( rest_is_field_included( 'post_count', $fields ) ) { + $data['post_count'] = (int) $site->post_count; + } + + $schema = $this->get_item_schema(); + + if ( ! empty( $schema['properties']['meta'] ) && rest_is_field_included( 'meta', $fields ) ) { + $data['meta'] = $this->meta->get_value( (int) $site->blog_id, $request ); + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + // Wrap the data in a response object. + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $response->add_links( $this->prepare_links( $site ) ); + } + + /** + * Filters a site returned from the API. + * + * Allows modification of the site right before it is returned. + * + * @since 7.2.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Site $site The original site object. + * @param WP_REST_Request $request Request used to generate the response. + */ + return apply_filters( 'rest_prepare_site', $response, $site, $request ); + } + + /** + * Checks a date against the site's timezone, wp_blogs stores GMT. + * + * @since 7.2.0 + * + * @param string $date_gmt The date as it is stored, in GMT. + * @param bool $gmt Optional. Whether to return the GMT date. Default false. + * @return string|null ISO8601/RFC3339 formatted date, null for an empty date. + */ + protected function prepare_date_response( $date_gmt, $gmt = false ) { + if ( empty( $date_gmt ) || '0000-00-00 00:00:00' === $date_gmt ) { + return null; + } + + if ( $gmt ) { + return mysql_to_rfc3339( $date_gmt ); + } + + return mysql_to_rfc3339( get_date_from_gmt( $date_gmt ) ); + } + + /** + * Prepares the links for the request. + * + * @since 7.2.0 + * + * @param WP_Site $site Site object. + * @return array Links for the given site. + */ + protected function prepare_links( $site ) { + $links = array( + 'self' => array( + 'href' => rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $site->blog_id ) ), + ), + 'collection' => array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ), + ); + + /** + * Filters the links for a site returned from the API. + * + * @since 7.2.0 + * + * @param array $links Links for the given site. + * @param WP_Site $site The site object. + */ + return apply_filters( 'rest_site_links', $links, $site ); + } + + /** + * Prepares a single site to be inserted into the database. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Request object. + * @return array|WP_Error Prepared site, otherwise WP_Error object. + */ + protected function prepare_item_for_database( $request ) { + $prepared_site = array(); + + // Schema defaults apply to POST only, so on an update anything left out is null. + foreach ( array( 'public', 'archived', 'mature', 'spam', 'deleted' ) as $status_field ) { + if ( isset( $request[ $status_field ] ) ) { + // The columns are TINYINT, the schema says boolean. + $prepared_site[ $status_field ] = (int) rest_sanitize_boolean( $request[ $status_field ] ); + } + } + + if ( isset( $request['lang_id'] ) ) { + $prepared_site['lang_id'] = (int) $request['lang_id']; + } + + /* + * Title, administrator and the initial options reach wp_initialize_site() + * through wp_insert_site(), so they only apply while the site is created. + */ + if ( WP_REST_Server::CREATABLE === $request->get_method() ) { + if ( isset( $request['title'] ) ) { + $prepared_site['title'] = $request['title']; + } + + 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 ) ); + } + + $prepared_site['user_id'] = (int) $request['user_id']; + } + } + + if ( isset( $request['network'] ) ) { + if ( ! get_network( $request['network'] ) ) { + return new WP_Error( 'rest_network_id_invalid', __( 'Invalid network ID.' ), array( 'status' => 400 ) ); + } + $prepared_site['network_id'] = (int) $request['network']; + } + + if ( isset( $request['path'] ) ) { + $prepared_site['path'] = $request['path']; + } + + if ( isset( $request['domain'] ) ) { + $prepared_site['domain'] = $request['domain']; + } + + /** + * Filters a site after it is prepared for the database. + * + * Allows modification of the site right after it is prepared for the database. + * + * @since 7.2.0 + * + * @param array $prepared_site The prepared site data for `wp_insert_site`. + * @param WP_REST_Request $request The current request. + */ + return apply_filters( 'rest_preprocess_site', $prepared_site, $request ); + } + + /** + * Retrieves the site's schema, conforming to JSON Schema. + * + * @since 7.2.0 + * + * @return array + */ + public function get_item_schema() { + $schema = array( + '$schema' => 'http://json-schema.org/schema#', + 'title' => 'site', + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the object.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'network' => array( + 'description' => __( 'The site\'s network ID. Default is the current network ID.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'domain' => array( + 'description' => __( 'Site domain.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'default' => '', + ), + 'path' => array( + 'description' => __( 'Site path.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'default' => '/', + ), + 'registered' => array( + 'description' => __( 'When the site was registered, in the site\'s timezone.' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'registered_gmt' => array( + 'description' => __( 'When the site was registered, as GMT.' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', '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' ), + 'readonly' => true, + ), + 'last_updated_gmt' => array( + 'description' => __( 'When the site was last updated, as GMT.' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'public' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'Whether the site is public. Default true.' ), + 'type' => 'boolean', + 'default' => true, + ), + 'archived' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'Whether the site is archived. Default false.' ), + 'type' => 'boolean', + 'default' => false, + ), + 'mature' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'Whether the site is mature. Default false.' ), + 'type' => 'boolean', + 'default' => false, + ), + 'spam' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'Whether the site is spam. Default false.' ), + 'type' => 'boolean', + 'default' => false, + ), + 'deleted' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'Whether the site is deleted. Default false.' ), + 'type' => 'boolean', + 'default' => false, + ), + 'lang_id' => array( + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'The site\'s language ID. Currently unused. Default 0.' ), + 'type' => 'integer', + 'default' => 0, + ), + 'blogname' => array( + 'description' => __( 'Site name, stored in the blogname option.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'siteurl' => array( + 'description' => __( 'Site address, stored in the siteurl option.' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'home' => array( + 'description' => __( 'Home address, stored in the home option.' ), + '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' ), + '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(), + ), + ), + ); + + $schema['properties']['meta'] = $this->meta->get_field_schema(); + + return $this->add_additional_fields_schema( $schema ); + } + + /** + * Retrieves the query params for collections. + * + * @since 7.2.0 + * + * @return array Sites collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + $query_params['context']['default'] = 'view'; + + $query_params['domain'] = array( + 'description' => __( 'Limit result set to sites assigned to specific domain. ' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ); + + $query_params['domain_exclude'] = array( + 'description' => __( 'Ensure result set excludes sites assigned to specific domain. ' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ); + + $query_params['path'] = array( + 'description' => __( 'Limit result set to sites assigned to specific path. ' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ); + + $query_params['path_exclude'] = array( + 'description' => __( 'Ensure result set excludes sites assigned to specific path. ' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ); + + $query_params['exclude'] = array( + 'description' => __( 'Ensure result set excludes specific IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'default' => array(), + ); + + $query_params['include'] = array( + 'description' => __( 'Limit result set to specific IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'default' => array(), + ); + + $query_params['offset'] = array( + 'description' => __( 'Offset the result set by a specific number of items.' ), + 'type' => 'integer', + ); + + $query_params['order'] = array( + 'description' => __( 'Order sort attribute ascending or descending.' ), + 'type' => 'string', + 'default' => 'asc', + 'enum' => array( + 'asc', + 'desc', + ), + ); + + $query_params['orderby'] = array( + 'description' => __( 'Sort collection by object attribute.' ), + 'type' => 'string', + 'default' => 'id', + 'enum' => array( + 'id', + 'domain', + 'path', + 'network_id', + 'last_updated', + 'registered', + 'domain_length', + 'path_length', + 'site__in', + 'network__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', + ); + + $status_descriptions = array( + 'public' => __( 'Limit result set to sites with a specific public status.' ), + 'archived' => __( 'Limit result set to sites with a specific archived status.' ), + 'mature' => __( 'Limit result set to sites with a specific mature status.' ), + 'spam' => __( 'Limit result set to sites with a specific spam status.' ), + 'deleted' => __( 'Limit result set to sites with a specific deleted status.' ), + ); + + foreach ( $status_descriptions as $status_param => $status_description ) { + // No default, an absent parameter must not filter the collection. + $query_params[ $status_param ] = array( + 'description' => $status_description, + 'type' => 'boolean', + ); + } + + $query_params['lang_id'] = array( + 'default' => array(), + 'description' => __( 'Limit result set to sites of specific language IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ); + + $query_params['lang_id_exclude'] = array( + 'default' => array(), + 'description' => __( 'Ensure result set excludes specific language IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ); + + $query_params['before'] = array( + 'description' => __( 'Limit response to sites registered before a given ISO8601 compliant date.' ), + 'type' => 'string', + 'format' => 'date-time', + ); + + $query_params['after'] = array( + 'description' => __( 'Limit response to sites registered after a given ISO8601 compliant date.' ), + 'type' => 'string', + 'format' => 'date-time', + ); + + $query_params['network'] = array( + 'default' => array(), + 'description' => __( 'Limit result set to sites of specific network IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ); + + $query_params['network_exclude'] = array( + 'default' => array(), + 'description' => __( 'Ensure result set excludes specific network IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ); + + /** + * Filter collection parameters for the sites controller. + * + * This filter registers the collection parameter, but does not map the + * collection parameter to an internal WP_Site_Query parameter. Use the + * `rest_site_query` filter to set WP_Site_Query parameters. + * + * @since 7.2.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_site_collection_params', $query_params ); + } + + /** + * Checks if a site can be edited or deleted. + * + * @since 7.2.0 + * + * @param object $site Site object. + * @return bool Whether the site can be edited or deleted. + */ + protected function check_edit_permission( $site ) { + if ( 0 === (int) get_current_user_id() ) { + return false; + } + + if ( ! is_multisite() ) { + return false; + } + + return current_user_can( 'manage_sites' ); + } +} diff --git a/src/wp-includes/rest-api/fields/class-wp-rest-site-meta-fields.php b/src/wp-includes/rest-api/fields/class-wp-rest-site-meta-fields.php new file mode 100644 index 0000000000000..c98f30a0ac8e3 --- /dev/null +++ b/src/wp-includes/rest-api/fields/class-wp-rest-site-meta-fields.php @@ -0,0 +1,54 @@ +user->create( + array( + 'role' => 'administrator', + 'user_login' => 'superadmin', + ) + ); + + update_site_option( 'site_admins', array( 'superadmin' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$superadmin_id ); + } + + public function set_up() { + parent::set_up(); + $this->endpoint = new WP_REST_Sites_Controller(); + } + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/sites', $routes ); + $this->assertCount( 2, $routes['/wp/v2/sites'] ); + $this->assertArrayHasKey( '/wp/v2/sites/(?P[\d]+)', $routes ); + $this->assertCount( 3, $routes['/wp/v2/sites/(?P[\d]+)'] ); + } + + public function test_context_param() { + wp_set_current_user( self::$superadmin_id ); + // Collection + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertEquals( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + // Single + $blog_id = self::factory()->blog->create(); + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertEquals( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + + public function test_get_items() { + wp_set_current_user( self::$superadmin_id ); + self::factory()->blog->create_many( 6 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $sites = $response->get_data(); + $this->assertCount( 7, $sites ); + } + + + public function test_get_item() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/nulla/' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $site = get_site( $blog_id ); + + $this->assertEquals( $blog_id, $data['id'] ); + $this->assertEquals( $site->domain, $data['domain'] ); + $this->assertEquals( '/nulla/', $data['path'] ); + $this->assertEquals( 1, $data['network'] ); + $this->assertEquals( 1, $data['public'] ); + } + + /** + * An unknown ID is a 404, not an empty site. + */ + public function test_get_item_invalid_id() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_site_invalid_id', $response, 404 ); + } + + public function test_create_item() { + 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', '/tempor/' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( '/tempor/', $data['path'] ); + $this->assertEquals( WP_TESTS_DOMAIN, $data['domain'] ); + + $site = get_site( $data['id'] ); + + $this->assertNotNull( $site ); + $this->assertEquals( '/tempor/', $site->path ); + } + + public function test_update_item() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/eiusmod/' ) ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'path', '/incididunt/' ); + $request->set_param( 'mature', 1 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( '/incididunt/', $data['path'] ); + $this->assertEquals( 1, $data['mature'] ); + $this->assertEquals( '/incididunt/', get_site( $blog_id )->path ); + } + + public function test_delete_item() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/amet/' ) ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertTrue( $data['deleted'] ); + $this->assertEquals( $blog_id, $data['previous']['id'] ); + $this->assertNull( get_site( $blog_id ) ); + } + + /** + * Deleting a site drops its tables. + */ + public function test_delete_item_uninitializes_the_site() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/aliqua/' ) ); + + $this->assertTrue( wp_is_site_initialized( $blog_id ) ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertFalse( wp_is_site_initialized( $blog_id ) ); + } + + /** + * Sites have no trash, so deleting has to be explicit. + */ + public function test_delete_item_requires_force() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/consectetur/' ) ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501 ); + $this->assertNotNull( get_site( $blog_id ) ); + } + + /** + * The main site of a network holds the network together. + */ + public function test_delete_main_site_is_not_allowed() { + wp_set_current_user( self::$superadmin_id ); + + $main_site_id = get_main_site_id(); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $main_site_id ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_delete_main_site', $response, 403 ); + $this->assertNotNull( get_site( $main_site_id ) ); + } + + public function test_prepare_item() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/labore/' ) ); + $site = get_site( $blog_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'context', 'edit' ); + + $data = $this->endpoint->prepare_item_for_response( $site, $request )->get_data(); + + $this->assertEquals( (int) $site->blog_id, $data['id'] ); + $this->assertEquals( (int) $site->site_id, $data['network'] ); + $this->assertEquals( $site->domain, $data['domain'] ); + $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->assertIsBool( $data['public'] ); + $this->assertIsInt( $data['post_count'] ); + } + + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $expected = array( + 'id', + 'network', + 'domain', + 'path', + 'registered', + 'registered_gmt', + 'last_updated', + 'last_updated_gmt', + 'public', + 'archived', + 'mature', + 'spam', + 'deleted', + 'lang_id', + 'blogname', + 'siteurl', + 'home', + 'post_count', + 'title', + 'user_id', + 'meta', + ); + + $this->assertEqualSets( $expected, array_keys( $properties ) ); + $this->assertTrue( $properties['id']['readonly'] ); + $this->assertTrue( $properties['registered']['readonly'] ); + $this->assertTrue( $properties['blogname']['readonly'] ); + $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'] ); + } + + /** + * The status flags are booleans, the dates are RFC3339 with a GMT counterpart. + */ + public function test_get_item_uses_the_schema_types() { + wp_set_current_user( self::$superadmin_id ); + + $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 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + foreach ( array( 'public', 'archived', 'mature', 'spam', 'deleted' ) as $flag ) { + $this->assertIsBool( $data[ $flag ], $flag ); + } + + $this->assertEquals( mysql_to_rfc3339( $site->registered ), $data['registered_gmt'] ); + $this->assertEquals( mysql_to_rfc3339( get_date_from_gmt( $site->registered ) ), $data['registered'] ); + $this->assertEquals( mysql_to_rfc3339( $site->last_updated ), $data['last_updated_gmt'] ); + } + + /** + * The collection can be narrowed down by status. + */ + public function test_get_items_filter_by_status() { + wp_set_current_user( self::$superadmin_id ); + + $archived = self::factory()->blog->create( array( 'path' => '/dolores/' ) ); + self::factory()->blog->create( array( 'path' => '/nemo/' ) ); + + wp_update_site( $archived, array( 'archived' => 1 ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'archived', true ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $data ); + $this->assertEquals( $archived, $data[0]['id'] ); + $this->assertEquals( 1, (int) $response->get_headers()['X-WP-Total'] ); + + $request->set_param( 'archived', false ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertNotContains( $archived, wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * Without the parameter the status does not narrow anything. + */ + public function test_get_items_without_status_filter_returns_every_site() { + wp_set_current_user( self::$superadmin_id ); + + $archived = self::factory()->blog->create( array( 'path' => '/officiis/' ) ); + + wp_update_site( $archived, array( 'archived' => 1 ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertContains( $archived, wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * The language IDs narrow the collection, in both directions. + */ + public function test_get_items_filter_by_lang_id() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/magni/' ) ); + + wp_update_site( $blog_id, array( 'lang_id' => 7 ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'lang_id', array( 7 ) ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 1, $data ); + $this->assertEquals( $blog_id, $data[0]['id'] ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'lang_id_exclude', array( 7 ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertNotContains( $blog_id, wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * Registration dates are GMT, so the boundaries are read as GMT. + */ + public function test_get_items_filter_by_registration_date() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/harum/' ) ); + + wp_update_site( $blog_id, array( 'registered' => '2019-06-01 12:00:00' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'before', '2019-07-01T00:00:00Z' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 1, $data ); + $this->assertEquals( $blog_id, $data[0]['id'] ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'after', '2019-07-01T00:00:00Z' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertNotContains( $blog_id, wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * A created site gets the title and the administrator that were asked for. + */ + public function test_create_item_sets_the_title_and_the_administrator() { + wp_set_current_user( self::$superadmin_id ); + + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + + $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 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $blog_id = $response->get_data()['id']; + + switch_to_blog( $blog_id ); + $blogname = get_option( 'blogname' ); + $is_admin = user_can( $user_id, 'manage_options' ); + restore_current_blog(); + + $this->assertEquals( 'Voluptas', $blogname ); + $this->assertTrue( $is_admin ); + $this->assertTrue( is_user_member_of_blog( $user_id, $blog_id ) ); + } + + /** + * An unknown administrator is refused before the site is created. + */ + public function test_create_item_rejects_an_unknown_user_id() { + 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 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'rest_site_invalid_user_id', $response->get_data()['code'] ); + + // The check runs before wp_insert_site(), so nothing was created. + $this->assertEquals( 0, get_blog_id_from_url( WP_TESTS_DOMAIN, '/quisquam/' ) ); + } + + /** + * Title and administrator belong to creation, an update does not accept them. + */ + public function test_update_item_does_not_accept_the_creation_fields() { + wp_set_current_user( self::$superadmin_id ); + + $routes = rest_get_server()->get_routes(); + $args = array(); + + foreach ( $routes['/wp/v2/sites/(?P[\d]+)'] as $handler ) { + if ( ! empty( $handler['methods']['PUT'] ) ) { + $args = $handler['args']; + } + } + + $this->assertArrayNotHasKey( 'title', $args ); + $this->assertArrayNotHasKey( 'user_id', $args ); + $this->assertArrayHasKey( 'domain', $args ); + } + + /** + * Filtering by user narrows the total, not just the current page. + */ + public function test_get_items_me_filter_reports_the_filtered_total() { + $blog_ids = self::factory()->blog->create_many( 3 ); + $user_id = self::factory()->user->create(); + + wp_set_current_user( $user_id ); + + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + + self::factory()->blog->create_many( 2 ); + + $expected = count( get_blogs_of_user( $user_id ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + + $response = rest_get_server()->dispatch( $request ); + $headers = $response->get_headers(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertLessThan( (int) get_sites( array( 'count' => true ) ), $expected ); + $this->assertCount( $expected, $response->get_data() ); + $this->assertEquals( $expected, (int) $headers['X-WP-Total'] ); + } + + /** + * A user without sites gets nothing, not everything. + */ + public function test_get_items_filter_user_without_sites() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create_many( 3 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', (string) REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $response = rest_get_server()->dispatch( $request ); + $headers = $response->get_headers(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 0, $response->get_data() ); + $this->assertEquals( 0, (int) $headers['X-WP-Total'] ); + } + + /** + * A site links to itself and to the collection. + */ + public function test_get_item_has_links() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/veniam/' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + $links = $response->get_links(); + + $this->assertArrayHasKey( 'self', $links ); + $this->assertArrayHasKey( 'collection', $links ); + $this->assertStringEndsWith( '/wp/v2/sites/' . $blog_id, $links['self'][0]['href'] ); + $this->assertStringEndsWith( '/wp/v2/sites', $links['collection'][0]['href'] ); + } + + /** + * Reading a site's options means switching to it, so avoid it when the + * fields that need it were not asked for. + */ + public function test_get_items_does_not_switch_blogs_for_table_columns() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create_many( 3 ); + + $switches = 0; + $counter = static function () use ( &$switches ) { + ++$switches; + }; + + add_action( 'switch_blog', $counter ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( '_fields', 'id,domain,path' ); + + $response = rest_get_server()->dispatch( $request ); + + remove_action( 'switch_blog', $counter ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 0, $switches ); + + $site = $response->get_data()[0]; + + foreach ( array( 'blogname', 'siteurl', 'home', 'post_count', 'meta' ) as $field ) { + $this->assertArrayNotHasKey( $field, $site ); + } + + $this->assertArrayHasKey( 'domain', $site ); + } + + /** + * A HEAD request answers with the headers and an empty body. + */ + public function test_head_request_returns_no_body() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create_many( 2 ); + + $request = new WP_REST_Request( 'HEAD', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $headers = $response->get_headers(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + $this->assertEquals( (int) get_sites( array( 'count' => true ) ), (int) $headers['X-WP-Total'] ); + } + + /** + * A HEAD request on a single site answers with an empty body, and the + * fields that need a switch stay untouched. + */ + public function test_head_request_on_a_single_site_returns_no_body() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/quidem/' ) ); + + $switches = 0; + $counter = static function () use ( &$switches ) { + ++$switches; + }; + + add_action( 'switch_blog', $counter ); + + $request = new WP_REST_Request( 'HEAD', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + remove_action( 'switch_blog', $counter ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + $this->assertSame( array(), $response->get_links() ); + $this->assertSame( 0, $switches ); + } + + /** + * The collection is ordered by ID, ascending. + */ + public function test_get_items_are_ordered_ascending() { + wp_set_current_user( self::$superadmin_id ); + + $blog_ids = self::factory()->blog->create_many( 3 ); + array_unshift( $blog_ids, 1 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( $blog_ids, wp_list_pluck( $response->get_data(), 'id' ) ); + } + + /** + * Ordering by an ID list falls back when there is no list. + */ + public function test_get_items_orderby_id_list_without_a_list() { + wp_set_current_user( self::$superadmin_id ); + + foreach ( array( 'site__in', 'network__in' ) as $orderby ) { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'orderby', $orderby ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status(), $orderby ); + $this->assertNotEmpty( $response->get_data(), $orderby ); + } + } + + /** + * The data is stored as sent, without added slashes. + */ + public function test_update_item_does_not_slash_the_stored_data() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/sit/' ) ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'path', "/o'brien/" ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( "/o'brien/", get_site( $blog_id )->path ); + } + + /** + * The status fields are stored when a site is created. + */ + public function test_create_item_stores_the_status_fields() { + 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', '/dolor/' ); + $request->set_param( 'public', 0 ); + $request->set_param( 'archived', 1 ); + $request->set_param( 'lang_id', 7 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $data = $response->get_data(); + $site = get_site( $data['id'] ); + + $this->assertEquals( 0, $site->public ); + $this->assertEquals( 1, $site->archived ); + $this->assertEquals( 7, $site->lang_id ); + } + + /** + * A partial update must not touch fields the request left out. + */ + public function test_update_item_keeps_fields_that_were_not_sent() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/lorem/' ) ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'archived', 1 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( 1, $data['archived'] ); + $this->assertEquals( '/lorem/', $data['path'] ); + $this->assertEquals( '/lorem/', get_site( $blog_id )->path ); + } + + /** + * The domain is left alone when the request does not carry one. + */ + public function test_update_item_keeps_the_domain() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/ipsum/' ) ); + $domain = get_site( $blog_id )->domain; + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'public', 0 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( $domain, get_site( $blog_id )->domain ); + $this->assertEquals( 0, get_site( $blog_id )->public ); + } + + /** + * Site meta is exposed through the endpoint. + * + * Registering under the `blog` meta type is what `add_site_meta()` and + * `get_site_meta()` do, so the controller has to read the same type. + */ + public function test_get_item_exposes_site_meta() { + if ( ! is_site_meta_supported() ) { + $this->markTestSkipped( 'Site meta is not supported on this installation.' ); + } + + wp_set_current_user( self::$superadmin_id ); + + register_meta( + 'blog', + 'rest_test_site_meta', + array( + 'type' => 'string', + 'single' => true, + 'show_in_rest' => true, + ) + ); + + $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 ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'meta', $data ); + $this->assertArrayHasKey( 'rest_test_site_meta', $data['meta'] ); + $this->assertEquals( 'from blogmeta', $data['meta']['rest_test_site_meta'] ); + + unregister_meta_key( 'blog', 'rest_test_site_meta' ); + } + + public function test_invalid_user_input() { + $this->assertEquals( array(), $this->endpoint->get_user_site_ids( false ) ); + $this->assertEquals( array(), $this->endpoint->get_user_site_ids( 0 ) ); + $this->assertEquals( array(), $this->endpoint->get_user_site_ids( '' ) ); + $this->assertEquals( array(), $this->endpoint->get_user_site_ids( REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); + $this->assertEquals( array(), $this->endpoint->get_user_site_ids( 999 ) ); + } + + public function test_valid_user_input() { + + $blog_ids = self::factory()->blog->create_many( 5 ); + $user_id = self::factory()->user->create(); + array_unshift( $blog_ids, 1 ); + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + + $this->assertEquals( $blog_ids, $this->endpoint->get_user_site_ids( $user_id ) ); + } + + public function test_get_items_filter_user() { + wp_set_current_user( self::$superadmin_id ); + $blog_ids = self::factory()->blog->create_many( 5 ); + $user_id = self::factory()->user->create(); + + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + array_unshift( $blog_ids, 1 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', (string) $user_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $sites = $response->get_data(); + $this->assertCount( 6, $sites ); + $this->assertEquals( $blog_ids, wp_list_pluck( $sites, 'id' ) ); + } + + public function test_get_items_me_filter_user() { + + $blog_ids = self::factory()->blog->create_many( 5 ); + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + array_unshift( $blog_ids, 1 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $sites = $response->get_data(); + $this->assertCount( 6, $sites ); + $this->assertEquals( $blog_ids, wp_list_pluck( $sites, 'id' ) ); + } + + public function test_get_items_filter_user_no_access() { + + $blog_ids = self::factory()->blog->create_many( 5 ); + $user_id = self::factory()->user->create(); + $user_id2 = self::factory()->user->create(); + wp_set_current_user( $user_id2 ); + + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + array_unshift( $blog_ids, 1 ); + $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() ); + } + + public function test_get_items_filter_with_includes_user() { + wp_set_current_user( self::$superadmin_id ); + $blog_ids = self::factory()->blog->create_many( 5 ); + $user_id = self::factory()->user->create(); + + foreach ( $blog_ids as $blog_id ) { + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + } + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', (string) $user_id ); + $request->set_param( 'include', $blog_ids[0] ); + $response = rest_get_server()->dispatch( $request ); + //$this->assertEquals( 200, $response->get_status() ); + $sites = $response->get_data(); + $this->assertCount( 1, $sites ); + $this->assertEquals( array( $blog_ids[0] ), wp_list_pluck( $sites, 'id' ) ); + } +} From cc81250200c93e846118fdc45b3f12d2d7e6dbab Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Sat, 22 Aug 2026 09:51:43 +0200 Subject: [PATCH 02/38] REST API: Add sites endpoints Introduces WP_REST_Sites_Controller plus tests. See #40365. --- src/wp-includes/rest-api.php | 6 +- .../class-wp-rest-sites-controller.php | 121 ++++-- .../tests/rest-api/rest-schema-setup.php | 2 + .../tests/rest-api/rest-sites-controller.php | 146 ++++++- tests/qunit/fixtures/wp-api-generated.js | 397 ++++++++++++++++++ 5 files changed, 638 insertions(+), 34 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 455f7e0a8ffe4..a3ed9e6611f94 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -325,10 +325,8 @@ function create_initial_rest_routes() { $controller->register_routes(); // Sites. - if ( is_multisite() ) { - $controller = new WP_REST_Sites_Controller(); - $controller->register_routes(); - } + $controller = new WP_REST_Sites_Controller(); + $controller->register_routes(); $search_handlers = array( new WP_REST_Post_Search_Handler(), 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 9d3b6b6c7cf4f..9bcbd88030034 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 @@ -408,6 +408,10 @@ public function get_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } + if ( is_super_admin() ) { + return true; + } + if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'manage_sites' ) ) { return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -452,6 +456,10 @@ public function create_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } + if ( is_super_admin() ) { + return true; + } + return current_user_can( 'create_sites' ); } @@ -519,7 +527,16 @@ public function create_item( $request ) { $schema = $this->get_item_schema(); - if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + if ( isset( $request['meta'] ) ) { + if ( empty( $schema['properties']['meta'] ) ) { + return new WP_Error( + 'rest_site_meta_not_supported', + /* translators: %s: database table name */ + sprintf( __( 'The %s table is not installed. Please run the network database upgrade.' ), $GLOBALS['wpdb']->blogmeta ), + array( 'status' => 400 ) + ); + } + $meta_update = $this->meta->update_value( $request['meta'], $site_id ); if ( is_wp_error( $meta_update ) ) { @@ -613,14 +630,13 @@ public function update_item( $request ) { $schema = $this->get_item_schema(); - if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { - if ( function_exists( 'is_site_meta_supported' ) && ! is_site_meta_supported() ) { - + if ( isset( $request['meta'] ) ) { + if ( empty( $schema['properties']['meta'] ) ) { return new WP_Error( - 'reset_site_meta_not_supported', + 'rest_site_meta_not_supported', /* translators: %s: database table name */ sprintf( __( 'The %s table is not installed. Please run the network database upgrade.' ), $GLOBALS['wpdb']->blogmeta ), - array( 'status' => 500 ) + array( 'status' => 400 ) ); } @@ -666,7 +682,7 @@ public function delete_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } - if ( ! current_user_can( 'delete_sites' ) ) { + if ( ! current_user_can( 'delete_sites' ) && ! is_super_admin() ) { return new WP_Error( 'rest_cannot_delete', __( 'Sorry, you are not allowed to delete this site.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -749,25 +765,65 @@ public function prepare_item_for_response( $site, $request ) { return apply_filters( 'rest_prepare_site', new WP_REST_Response( array() ), $site, $request ); } - $data = array( - 'id' => (int) $site->blog_id, - 'network' => (int) $site->site_id, - 'domain' => $site->domain, - 'path' => $site->path, - 'registered' => $this->prepare_date_response( $site->registered ), - 'registered_gmt' => $this->prepare_date_response( $site->registered, true ), - 'last_updated' => $this->prepare_date_response( $site->last_updated ), - 'last_updated_gmt' => $this->prepare_date_response( $site->last_updated, true ), - 'public' => (bool) $site->public, - 'archived' => (bool) $site->archived, - 'mature' => (bool) $site->mature, - 'spam' => (bool) $site->spam, - 'deleted' => (bool) $site->deleted, - 'lang_id' => (int) $site->lang_id, - ); - + $data = array(); $fields = $this->get_fields_for_response( $request ); + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = (int) $site->blog_id; + } + + if ( rest_is_field_included( 'network', $fields ) ) { + $data['network'] = (int) $site->site_id; + } + + if ( rest_is_field_included( 'domain', $fields ) ) { + $data['domain'] = $site->domain; + } + + if ( rest_is_field_included( 'path', $fields ) ) { + $data['path'] = $site->path; + } + + if ( rest_is_field_included( 'registered', $fields ) ) { + $data['registered'] = $this->prepare_date_response( $site->registered ); + } + + if ( rest_is_field_included( 'registered_gmt', $fields ) ) { + $data['registered_gmt'] = $this->prepare_date_response( $site->registered, true ); + } + + if ( rest_is_field_included( 'last_updated', $fields ) ) { + $data['last_updated'] = $this->prepare_date_response( $site->last_updated ); + } + + if ( rest_is_field_included( 'last_updated_gmt', $fields ) ) { + $data['last_updated_gmt'] = $this->prepare_date_response( $site->last_updated, true ); + } + + if ( rest_is_field_included( 'public', $fields ) ) { + $data['public'] = (bool) $site->public; + } + + if ( rest_is_field_included( 'archived', $fields ) ) { + $data['archived'] = (bool) $site->archived; + } + + if ( rest_is_field_included( 'mature', $fields ) ) { + $data['mature'] = (bool) $site->mature; + } + + if ( rest_is_field_included( 'spam', $fields ) ) { + $data['spam'] = (bool) $site->spam; + } + + if ( rest_is_field_included( 'deleted', $fields ) ) { + $data['deleted'] = (bool) $site->deleted; + } + + if ( rest_is_field_included( 'lang_id', $fields ) ) { + $data['lang_id'] = (int) $site->lang_id; + } + // These four are not columns of the sites table. Reading one switches to the site. if ( rest_is_field_included( 'blogname', $fields ) ) { $data['blogname'] = $site->blogname; @@ -943,6 +999,10 @@ protected function prepare_item_for_database( $request ) { * @return array */ public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + $schema = array( '$schema' => 'http://json-schema.org/schema#', 'title' => 'site', @@ -1074,9 +1134,14 @@ public function get_item_schema() { ), ); - $schema['properties']['meta'] = $this->meta->get_field_schema(); + // 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(); + } + + $this->schema = $schema; - return $this->add_additional_fields_schema( $schema ); + return $this->add_additional_fields_schema( $this->schema ); } /** @@ -1273,6 +1338,10 @@ protected function check_edit_permission( $site ) { return false; } + if ( is_super_admin() ) { + return true; + } + return current_user_can( 'manage_sites' ); } } diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 9ee6cc4cbed16..641453414ee18 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -138,6 +138,8 @@ public function test_expected_routes_in_schema() { '/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords/(?P[\\w\\-]+)', '/wp/v2/comments', '/wp/v2/comments/(?P[\\d]+)', + '/wp/v2/sites', + '/wp/v2/sites/(?P[\\d]+)', '/wp/v2/global-styles/(?P[\/\d+]+)', '/wp/v2/global-styles/(?P[\d]+)/revisions', '/wp/v2/global-styles/(?P[\d]+)/revisions/(?P[\d]+)', diff --git a/tests/phpunit/tests/rest-api/rest-sites-controller.php b/tests/phpunit/tests/rest-api/rest-sites-controller.php index 9a77554510038..e16d7310ea0d9 100644 --- a/tests/phpunit/tests/rest-api/rest-sites-controller.php +++ b/tests/phpunit/tests/rest-api/rest-sites-controller.php @@ -10,7 +10,7 @@ * @group restapi * @group ms-required * - * @covers WP_REST_Sites_Controller + * @coversDefaultClass WP_REST_Sites_Controller */ class WP_Test_REST_Sites_Controller extends WP_Test_REST_Controller_Testcase { @@ -41,6 +41,10 @@ public function set_up() { $this->endpoint = new WP_REST_Sites_Controller(); } + /** + * @ticket 40365 + * @covers ::register_routes + */ public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/sites', $routes ); @@ -49,6 +53,10 @@ public function test_register_routes() { $this->assertCount( 3, $routes['/wp/v2/sites/(?P[\d]+)'] ); } + /** + * @ticket 40365 + * @covers ::get_context_param + */ public function test_context_param() { wp_set_current_user( self::$superadmin_id ); // Collection @@ -66,7 +74,10 @@ public function test_context_param() { $this->assertEquals( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); } - + /** + * @ticket 40365 + * @covers ::get_items + */ public function test_get_items() { wp_set_current_user( self::$superadmin_id ); self::factory()->blog->create_many( 6 ); @@ -77,7 +88,10 @@ public function test_get_items() { $this->assertCount( 7, $sites ); } - + /** + * @ticket 40365 + * @covers ::get_item + */ public function test_get_item() { wp_set_current_user( self::$superadmin_id ); @@ -100,6 +114,9 @@ public function test_get_item() { /** * An unknown ID is a 404, not an empty site. + * + * @ticket 40365 + * @covers ::get_item */ public function test_get_item_invalid_id() { wp_set_current_user( self::$superadmin_id ); @@ -110,6 +127,10 @@ public function test_get_item_invalid_id() { $this->assertErrorResponse( 'rest_site_invalid_id', $response, 404 ); } + /** + * @ticket 40365 + * @covers ::create_item + */ public function test_create_item() { wp_set_current_user( self::$superadmin_id ); @@ -132,6 +153,10 @@ public function test_create_item() { $this->assertEquals( '/tempor/', $site->path ); } + /** + * @ticket 40365 + * @covers ::update_item + */ public function test_update_item() { wp_set_current_user( self::$superadmin_id ); @@ -152,6 +177,10 @@ public function test_update_item() { $this->assertEquals( '/incididunt/', get_site( $blog_id )->path ); } + /** + * @ticket 40365 + * @covers ::delete_item + */ public function test_delete_item() { wp_set_current_user( self::$superadmin_id ); @@ -173,6 +202,9 @@ public function test_delete_item() { /** * Deleting a site drops its tables. + * + * @ticket 40365 + * @covers ::delete_item */ public function test_delete_item_uninitializes_the_site() { wp_set_current_user( self::$superadmin_id ); @@ -192,6 +224,9 @@ public function test_delete_item_uninitializes_the_site() { /** * Sites have no trash, so deleting has to be explicit. + * + * @ticket 40365 + * @covers ::delete_item */ public function test_delete_item_requires_force() { wp_set_current_user( self::$superadmin_id ); @@ -207,6 +242,9 @@ public function test_delete_item_requires_force() { /** * The main site of a network holds the network together. + * + * @ticket 40365 + * @covers ::delete_item */ public function test_delete_main_site_is_not_allowed() { wp_set_current_user( self::$superadmin_id ); @@ -222,6 +260,10 @@ public function test_delete_main_site_is_not_allowed() { $this->assertNotNull( get_site( $main_site_id ) ); } + /** + * @ticket 40365 + * @covers ::prepare_item_for_response + */ public function test_prepare_item() { wp_set_current_user( self::$superadmin_id ); @@ -245,6 +287,10 @@ public function test_prepare_item() { $this->assertIsInt( $data['post_count'] ); } + /** + * @ticket 40365 + * @covers ::get_item_schema + */ public function test_get_item_schema() { $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/sites' ); $response = rest_get_server()->dispatch( $request ); @@ -275,6 +321,11 @@ public function test_get_item_schema() { 'meta', ); + if ( ! is_site_meta_supported() ) { + // The meta property is only registered when the blogmeta table exists. + $expected = array_values( array_diff( $expected, array( 'meta' ) ) ); + } + $this->assertEqualSets( $expected, array_keys( $properties ) ); $this->assertTrue( $properties['id']['readonly'] ); $this->assertTrue( $properties['registered']['readonly'] ); @@ -289,6 +340,9 @@ public function test_get_item_schema() { /** * The status flags are booleans, the dates are RFC3339 with a GMT counterpart. + * + * @ticket 40365 + * @covers ::prepare_item_for_response */ public function test_get_item_uses_the_schema_types() { wp_set_current_user( self::$superadmin_id ); @@ -311,6 +365,9 @@ public function test_get_item_uses_the_schema_types() { /** * The collection can be narrowed down by status. + * + * @ticket 40365 + * @covers ::get_items */ public function test_get_items_filter_by_status() { wp_set_current_user( self::$superadmin_id ); @@ -340,6 +397,9 @@ public function test_get_items_filter_by_status() { /** * Without the parameter the status does not narrow anything. + * + * @ticket 40365 + * @covers ::get_items */ public function test_get_items_without_status_filter_returns_every_site() { wp_set_current_user( self::$superadmin_id ); @@ -356,6 +416,9 @@ public function test_get_items_without_status_filter_returns_every_site() { /** * The language IDs narrow the collection, in both directions. + * + * @ticket 40365 + * @covers ::get_items */ public function test_get_items_filter_by_lang_id() { wp_set_current_user( self::$superadmin_id ); @@ -383,6 +446,9 @@ public function test_get_items_filter_by_lang_id() { /** * Registration dates are GMT, so the boundaries are read as GMT. + * + * @ticket 40365 + * @covers ::get_items */ public function test_get_items_filter_by_registration_date() { wp_set_current_user( self::$superadmin_id ); @@ -410,6 +476,9 @@ public function test_get_items_filter_by_registration_date() { /** * A created site gets the title and the administrator that were asked for. + * + * @ticket 40365 + * @covers ::create_item */ public function test_create_item_sets_the_title_and_the_administrator() { wp_set_current_user( self::$superadmin_id ); @@ -440,6 +509,9 @@ public function test_create_item_sets_the_title_and_the_administrator() { /** * An unknown administrator is refused before the site is created. + * + * @ticket 40365 + * @covers ::create_item */ public function test_create_item_rejects_an_unknown_user_id() { wp_set_current_user( self::$superadmin_id ); @@ -460,6 +532,9 @@ public function test_create_item_rejects_an_unknown_user_id() { /** * Title and administrator belong to creation, an update does not accept them. + * + * @ticket 40365 + * @covers ::update_item */ public function test_update_item_does_not_accept_the_creation_fields() { wp_set_current_user( self::$superadmin_id ); @@ -480,6 +555,9 @@ public function test_update_item_does_not_accept_the_creation_fields() { /** * Filtering by user narrows the total, not just the current page. + * + * @ticket 40365 + * @covers ::get_items */ public function test_get_items_me_filter_reports_the_filtered_total() { $blog_ids = self::factory()->blog->create_many( 3 ); @@ -509,6 +587,9 @@ public function test_get_items_me_filter_reports_the_filtered_total() { /** * A user without sites gets nothing, not everything. + * + * @ticket 40365 + * @covers ::get_items */ public function test_get_items_filter_user_without_sites() { wp_set_current_user( self::$superadmin_id ); @@ -528,6 +609,9 @@ public function test_get_items_filter_user_without_sites() { /** * A site links to itself and to the collection. + * + * @ticket 40365 + * @covers ::prepare_links */ public function test_get_item_has_links() { wp_set_current_user( self::$superadmin_id ); @@ -547,6 +631,9 @@ public function test_get_item_has_links() { /** * Reading a site's options means switching to it, so avoid it when the * fields that need it were not asked for. + * + * @ticket 40365 + * @covers ::get_items */ public function test_get_items_does_not_switch_blogs_for_table_columns() { wp_set_current_user( self::$superadmin_id ); @@ -581,6 +668,9 @@ public function test_get_items_does_not_switch_blogs_for_table_columns() { /** * A HEAD request answers with the headers and an empty body. + * + * @ticket 40365 + * @covers ::get_items */ public function test_head_request_returns_no_body() { wp_set_current_user( self::$superadmin_id ); @@ -599,6 +689,9 @@ public function test_head_request_returns_no_body() { /** * A HEAD request on a single site answers with an empty body, and the * fields that need a switch stay untouched. + * + * @ticket 40365 + * @covers ::get_item */ public function test_head_request_on_a_single_site_returns_no_body() { wp_set_current_user( self::$superadmin_id ); @@ -625,6 +718,9 @@ public function test_head_request_on_a_single_site_returns_no_body() { /** * The collection is ordered by ID, ascending. + * + * @ticket 40365 + * @covers ::get_items */ public function test_get_items_are_ordered_ascending() { wp_set_current_user( self::$superadmin_id ); @@ -641,6 +737,9 @@ public function test_get_items_are_ordered_ascending() { /** * Ordering by an ID list falls back when there is no list. + * + * @ticket 40365 + * @covers ::get_items */ public function test_get_items_orderby_id_list_without_a_list() { wp_set_current_user( self::$superadmin_id ); @@ -658,6 +757,9 @@ public function test_get_items_orderby_id_list_without_a_list() { /** * The data is stored as sent, without added slashes. + * + * @ticket 40365 + * @covers ::update_item */ public function test_update_item_does_not_slash_the_stored_data() { wp_set_current_user( self::$superadmin_id ); @@ -675,6 +777,9 @@ public function test_update_item_does_not_slash_the_stored_data() { /** * The status fields are stored when a site is created. + * + * @ticket 40365 + * @covers ::create_item */ public function test_create_item_stores_the_status_fields() { wp_set_current_user( self::$superadmin_id ); @@ -700,6 +805,9 @@ public function test_create_item_stores_the_status_fields() { /** * A partial update must not touch fields the request left out. + * + * @ticket 40365 + * @covers ::update_item */ public function test_update_item_keeps_fields_that_were_not_sent() { wp_set_current_user( self::$superadmin_id ); @@ -722,6 +830,9 @@ public function test_update_item_keeps_fields_that_were_not_sent() { /** * The domain is left alone when the request does not carry one. + * + * @ticket 40365 + * @covers ::update_item */ public function test_update_item_keeps_the_domain() { wp_set_current_user( self::$superadmin_id ); @@ -744,6 +855,9 @@ public function test_update_item_keeps_the_domain() { * * Registering under the `blog` meta type is what `add_site_meta()` and * `get_site_meta()` do, so the controller has to read the same type. + * + * @ticket 40365 + * @covers ::get_item */ public function test_get_item_exposes_site_meta() { if ( ! is_site_meta_supported() ) { @@ -779,6 +893,10 @@ public function test_get_item_exposes_site_meta() { unregister_meta_key( 'blog', 'rest_test_site_meta' ); } + /** + * @ticket 40365 + * @covers ::get_user_site_ids + */ public function test_invalid_user_input() { $this->assertEquals( array(), $this->endpoint->get_user_site_ids( false ) ); $this->assertEquals( array(), $this->endpoint->get_user_site_ids( 0 ) ); @@ -787,6 +905,10 @@ public function test_invalid_user_input() { $this->assertEquals( array(), $this->endpoint->get_user_site_ids( 999 ) ); } + /** + * @ticket 40365 + * @covers ::get_user_site_ids + */ public function test_valid_user_input() { $blog_ids = self::factory()->blog->create_many( 5 ); @@ -799,6 +921,10 @@ public function test_valid_user_input() { $this->assertEquals( $blog_ids, $this->endpoint->get_user_site_ids( $user_id ) ); } + /** + * @ticket 40365 + * @covers ::get_items + */ public function test_get_items_filter_user() { wp_set_current_user( self::$superadmin_id ); $blog_ids = self::factory()->blog->create_many( 5 ); @@ -817,6 +943,10 @@ public function test_get_items_filter_user() { $this->assertEquals( $blog_ids, wp_list_pluck( $sites, 'id' ) ); } + /** + * @ticket 40365 + * @covers ::get_items + */ public function test_get_items_me_filter_user() { $blog_ids = self::factory()->blog->create_many( 5 ); @@ -835,6 +965,10 @@ public function test_get_items_me_filter_user() { $this->assertEquals( $blog_ids, wp_list_pluck( $sites, 'id' ) ); } + /** + * @ticket 40365 + * @covers ::get_items_permissions_check + */ public function test_get_items_filter_user_no_access() { $blog_ids = self::factory()->blog->create_many( 5 ); @@ -852,6 +986,10 @@ public function test_get_items_filter_user_no_access() { $this->assertEquals( 403, $response->get_status() ); } + /** + * @ticket 40365 + * @covers ::get_items + */ public function test_get_items_filter_with_includes_user() { wp_set_current_user( self::$superadmin_id ); $blog_ids = self::factory()->blog->create_many( 5 ); @@ -864,7 +1002,7 @@ public function test_get_items_filter_with_includes_user() { $request->set_param( 'user', (string) $user_id ); $request->set_param( 'include', $blog_ids[0] ); $response = rest_get_server()->dispatch( $request ); - //$this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 200, $response->get_status() ); $sites = $response->get_data(); $this->assertCount( 1, $sites ); $this->assertEquals( array( $blog_ids[0] ), wp_list_pluck( $sites, 'id' ) ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 4a2d5a3ac7ea8..d6628117ceb94 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -10877,6 +10877,403 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/sites": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "domain": { + "description": "Limit result set to sites assigned to specific domain. ", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "domain_exclude": { + "description": "Ensure result set excludes sites assigned to specific domain. ", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "path": { + "description": "Limit result set to sites assigned to specific path. ", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "path_exclude": { + "description": "Ensure result set excludes sites assigned to specific path. ", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "asc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by object attribute.", + "type": "string", + "default": "id", + "enum": [ + "id", + "domain", + "path", + "network_id", + "last_updated", + "registered", + "domain_length", + "path_length", + "site__in", + "network__in" + ], + "required": false + }, + "user": { + "description": "Limit result set to the sites a user is a member of. Accepts a user ID or \"me\".", + "type": "string", + "required": false + }, + "public": { + "description": "Limit result set to sites with a specific public status.", + "type": "boolean", + "required": false + }, + "archived": { + "description": "Limit result set to sites with a specific archived status.", + "type": "boolean", + "required": false + }, + "mature": { + "description": "Limit result set to sites with a specific mature status.", + "type": "boolean", + "required": false + }, + "spam": { + "description": "Limit result set to sites with a specific spam status.", + "type": "boolean", + "required": false + }, + "deleted": { + "description": "Limit result set to sites with a specific deleted status.", + "type": "boolean", + "required": false + }, + "lang_id": { + "default": [], + "description": "Limit result set to sites of specific language IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + }, + "lang_id_exclude": { + "default": [], + "description": "Ensure result set excludes specific language IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + }, + "before": { + "description": "Limit response to sites registered before a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "after": { + "description": "Limit response to sites registered after a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "network": { + "default": [], + "description": "Limit result set to sites of specific network IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + }, + "network_exclude": { + "default": [], + "description": "Ensure result set excludes specific network IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "args": { + "network": { + "description": "The site's network ID. Default is the current network ID.", + "type": "integer", + "required": false + }, + "domain": { + "default": "", + "description": "Site domain.", + "type": "string", + "required": false + }, + "path": { + "default": "/", + "description": "Site path.", + "type": "string", + "required": false + }, + "public": { + "default": true, + "description": "Whether the site is public. Default true.", + "type": "boolean", + "required": false + }, + "archived": { + "default": false, + "description": "Whether the site is archived. Default false.", + "type": "boolean", + "required": false + }, + "mature": { + "default": false, + "description": "Whether the site is mature. Default false.", + "type": "boolean", + "required": false + }, + "spam": { + "default": false, + "description": "Whether the site is spam. Default false.", + "type": "boolean", + "required": false + }, + "deleted": { + "default": false, + "description": "Whether the site is deleted. Default false.", + "type": "boolean", + "required": false + }, + "lang_id": { + "default": 0, + "description": "The site's language ID. Currently unused. Default 0.", + "type": "integer", + "required": false + }, + "title": { + "description": "Site title, set when the site is created. Default is the word \"Site\" followed by the site ID.", + "type": "string", + "required": false + }, + "user_id": { + "description": "User ID of the site administrator, set when the site is created.", + "type": "integer", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/sites" + } + ] + } + }, + "/wp/v2/sites/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "id": { + "description": "Unique identifier for the object.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "args": { + "id": { + "description": "Unique identifier for the object.", + "type": "integer", + "required": false + }, + "network": { + "description": "The site's network ID. Default is the current network ID.", + "type": "integer", + "required": false + }, + "domain": { + "description": "Site domain.", + "type": "string", + "required": false + }, + "path": { + "description": "Site path.", + "type": "string", + "required": false + }, + "public": { + "description": "Whether the site is public. Default true.", + "type": "boolean", + "required": false + }, + "archived": { + "description": "Whether the site is archived. Default false.", + "type": "boolean", + "required": false + }, + "mature": { + "description": "Whether the site is mature. Default false.", + "type": "boolean", + "required": false + }, + "spam": { + "description": "Whether the site is spam. Default false.", + "type": "boolean", + "required": false + }, + "deleted": { + "description": "Whether the site is deleted. Default false.", + "type": "boolean", + "required": false + }, + "lang_id": { + "description": "The site's language ID. Currently unused. Default 0.", + "type": "integer", + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": { + "id": { + "description": "Unique identifier for the object.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Required to be true, as sites do not support trashing.", + "required": false + } + } + } + ] + }, "/wp/v2/search": { "namespace": "wp/v2", "methods": [ From b447451c4364e465f0c30327fdf17709e6e96e70 Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Tue, 25 Aug 2026 22:28:48 +0200 Subject: [PATCH 03/38] REST API: Drop is_super_admin() from sites checks --- .../endpoints/class-wp-rest-sites-controller.php | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) 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 9bcbd88030034..09903dc2b96cb 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 @@ -408,10 +408,6 @@ public function get_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } - if ( is_super_admin() ) { - return true; - } - if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'manage_sites' ) ) { return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -456,10 +452,6 @@ public function create_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } - if ( is_super_admin() ) { - return true; - } - return current_user_can( 'create_sites' ); } @@ -682,7 +674,7 @@ public function delete_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } - if ( ! current_user_can( 'delete_sites' ) && ! is_super_admin() ) { + if ( ! current_user_can( 'delete_sites' ) ) { return new WP_Error( 'rest_cannot_delete', __( 'Sorry, you are not allowed to delete this site.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -1338,10 +1330,6 @@ protected function check_edit_permission( $site ) { return false; } - if ( is_super_admin() ) { - return true; - } - return current_user_can( 'manage_sites' ); } } From 375b13612929d9928fe7d8d598d181af8bde72b0 Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Fri, 28 Aug 2026 15:11:02 +0100 Subject: [PATCH 04/38] Tests: Add unit tests for WP_REST_Site_Meta_Fields functionality --- .../tests/rest-api/wpRestSiteMetaFields.php | 467 ++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php diff --git a/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php b/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php new file mode 100644 index 0000000000000..24893503d46c2 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php @@ -0,0 +1,467 @@ +user->create( + array( + 'role' => 'administrator', + 'user_login' => 'superadmin', + ) + ); + + update_site_option( 'site_admins', array( 'superadmin' ) ); + self::$site_id = $factory->blog->create( + array( + 'path' => '/site-meta/', + ) + ); + } + + public static function wpTearDownAfterClass() { + $GLOBALS['wp_meta_keys'] = self::$wp_meta_keys_saved; + update_site_option( 'site_admins', self::$site_admins_saved ); + + wp_delete_site( self::$site_id ); + self::delete_user( self::$superadmin_id ); + } + + public function set_up() { + parent::set_up(); + + if ( ! is_site_meta_supported() ) { + $this->markTestSkipped( 'Site meta is not supported on this installation.' ); + } + + wp_set_current_user( self::$superadmin_id ); + + register_meta( + 'blog', + 'test_single', + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + ) + ); + register_meta( + 'blog', + 'test_multi', + array( + 'show_in_rest' => true, + 'single' => false, + 'type' => 'string', + ) + ); + register_meta( + 'blog', + 'test_bad_auth', + array( + 'show_in_rest' => true, + 'single' => true, + 'auth_callback' => '__return_false', + 'type' => 'string', + ) + ); + register_meta( + 'blog', + 'test_bad_auth_multi', + array( + 'show_in_rest' => true, + 'single' => false, + 'auth_callback' => '__return_false', + 'type' => 'string', + ) + ); + register_meta( 'blog', 'test_no_rest', array() ); + register_meta( + 'blog', + 'test_rest_disabled', + array( + 'show_in_rest' => false, + 'type' => 'string', + ) + ); + register_meta( + 'blog', + 'test_custom_schema', + array( + 'single' => true, + 'type' => 'integer', + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'number', + ), + ), + ) + ); + register_meta( + 'blog', + 'test_custom_schema_multi', + array( + 'single' => false, + 'type' => 'integer', + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'number', + ), + ), + ) + ); + register_meta( + 'blog', + 'test_invalid_type', + array( + 'single' => true, + 'type' => 'lalala', + 'show_in_rest' => true, + ) + ); + register_meta( + 'blog', + 'test_no_type', + array( + 'single' => true, + 'type' => null, + 'show_in_rest' => true, + ) + ); + register_meta( + 'blog', + 'test_custom_name', + array( + 'single' => true, + 'type' => 'string', + 'show_in_rest' => array( + 'name' => 'new_name', + ), + ) + ); + register_meta( + 'blog', + 'test_custom_name_multi', + array( + 'single' => false, + 'type' => 'string', + 'show_in_rest' => array( + 'name' => 'new_name_multi', + ), + ) + ); + + $GLOBALS['wp_rest_server'] = new Spy_REST_Server(); + do_action( 'rest_api_init', $GLOBALS['wp_rest_server'] ); + } + + protected function grant_write_permission() { + wp_set_current_user( self::$superadmin_id ); + } + + protected function reload_rest_server() { + $GLOBALS['wp_rest_server'] = new Spy_REST_Server(); + do_action( 'rest_api_init', $GLOBALS['wp_rest_server'] ); + } + + /** + * @covers ::get_item + */ + 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 ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'meta', $data ); + + $meta = (array) $data['meta']; + $this->assertArrayHasKey( 'test_single', $meta ); + $this->assertSame( 'testvalue', $meta['test_single'] ); + } + + /** + * @depends test_get_value + * @covers ::get_item + */ + 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 ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertArrayHasKey( 'test_multi', $meta ); + $this->assertIsArray( $meta['test_multi'] ); + $this->assertContains( 'value1', $meta['test_multi'] ); + + add_site_meta( self::$site_id, 'test_multi', 'value2' ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertContains( 'value1', $meta['test_multi'] ); + $this->assertContains( 'value2', $meta['test_multi'] ); + } + + /** + * @depends test_get_value + * @covers ::get_item + */ + 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 ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertArrayNotHasKey( 'test_unregistered', $meta ); + } + + /** + * @depends test_get_value + * @covers ::get_item + */ + 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 ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertArrayNotHasKey( 'test_no_rest', $meta ); + } + + /** + * @depends test_get_value + * @covers ::get_item + */ + 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 ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertArrayNotHasKey( 'test_rest_disabled', $meta ); + } + + /** + * @covers ::get_item + */ + public function test_get_value_types() { + register_meta( + 'blog', + 'test_string', + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'string', + ) + ); + register_meta( + 'blog', + 'test_number', + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'number', + ) + ); + register_meta( + 'blog', + 'test_bool', + array( + 'show_in_rest' => true, + 'single' => true, + 'type' => 'boolean', + ) + ); + + $GLOBALS['wp_rest_server'] = new Spy_REST_Server(); + do_action( 'rest_api_init', $GLOBALS['wp_rest_server'] ); + + update_site_meta( self::$site_id, 'test_string', 42 ); + 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 ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + + $this->assertArrayHasKey( 'test_string', $meta ); + $this->assertIsString( $meta['test_string'] ); + $this->assertSame( '42', $meta['test_string'] ); + + $this->assertArrayHasKey( 'test_number', $meta ); + $this->assertIsFloat( $meta['test_number'] ); + $this->assertSame( 42.0, $meta['test_number'] ); + + $this->assertArrayHasKey( 'test_bool', $meta ); + $this->assertIsBool( $meta['test_bool'] ); + $this->assertTrue( $meta['test_bool'] ); + } + + /** + * @covers ::get_item + */ + 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 ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'meta', $data ); + + $meta = (array) $data['meta']; + $this->assertArrayHasKey( 'new_name', $meta ); + $this->assertSame( 'janet', $meta['new_name'] ); + } + + /** + * @covers ::get_item_schema + */ + public function test_get_item_schema_omits_meta_when_site_meta_is_unsupported() { + add_filter( 'pre_site_option_site_meta_supported', '__return_zero' ); + $this->reload_rest_server(); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayNotHasKey( 'meta', $data['schema']['properties'] ); + } + + /** + * @depends test_get_value + * @covers ::update_item + */ + public function test_set_value() { + $this->grant_write_permission(); + + $data = array( + 'meta' => array( + 'test_single' => 'test_value', + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_body_params( $data ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $meta = get_site_meta( self::$site_id, 'test_single', false ); + $this->assertNotEmpty( $meta ); + $this->assertCount( 1, $meta ); + $this->assertSame( 'test_value', $meta[0] ); + + $data = $response->get_data(); + $meta = (array) $data['meta']; + $this->assertArrayHasKey( 'test_single', $meta ); + $this->assertSame( 'test_value', $meta['test_single'] ); + } + + /** + * @covers ::create_item + */ + public function test_create_item_rejects_meta_when_site_meta_is_unsupported() { + add_filter( 'pre_site_option_site_meta_supported', '__return_zero' ); + $this->reload_rest_server(); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/meta-disabled/' ); + $request->set_param( + 'meta', + array( + 'test_single' => 'test_value', + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_site_meta_not_supported', $response, 400 ); + } + + /** + * @covers ::update_item + */ + public function test_update_item_rejects_meta_when_site_meta_is_unsupported() { + add_filter( 'pre_site_option_site_meta_supported', '__return_zero' ); + $this->reload_rest_server(); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_param( + 'meta', + array( + 'test_single' => 'test_value', + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_site_meta_not_supported', $response, 400 ); + } + + /** + * @depends test_get_value + * @covers ::update_item + */ + public function test_set_duplicate_single_value() { + update_site_meta( self::$site_id, 'test_single', 'test_value' ); + $this->assertSame( 'test_value', get_site_meta( self::$site_id, 'test_single', true ) ); + + $this->grant_write_permission(); + + $data = array( + 'meta' => array( + 'test_single' => 'test_value', + ), + ); + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/sites/%d', self::$site_id ) ); + $request->set_body_params( $data ); + + $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 ) ); + } +} From b7a51db08ea845dde94b1748376e5ad823025b31 Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Fri, 28 Aug 2026 15:51:55 +0100 Subject: [PATCH 05/38] Tests: Rename rest-sites-controller.php to wpRestSitesController.php and add missing group annotations --- ...ntroller.php => wpRestSitesController.php} | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) rename tests/phpunit/tests/rest-api/{rest-sites-controller.php => wpRestSitesController.php} (91%) diff --git a/tests/phpunit/tests/rest-api/rest-sites-controller.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php similarity index 91% rename from tests/phpunit/tests/rest-api/rest-sites-controller.php rename to tests/phpunit/tests/rest-api/wpRestSitesController.php index e16d7310ea0d9..789eb40852f2c 100644 --- a/tests/phpunit/tests/rest-api/rest-sites-controller.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -8,7 +8,6 @@ * @since 7.2.0 * * @group restapi - * @group ms-required * * @coversDefaultClass WP_REST_Sites_Controller */ @@ -56,6 +55,7 @@ public function test_register_routes() { /** * @ticket 40365 * @covers ::get_context_param + * @group ms-required */ public function test_context_param() { wp_set_current_user( self::$superadmin_id ); @@ -77,6 +77,7 @@ public function test_context_param() { /** * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items() { wp_set_current_user( self::$superadmin_id ); @@ -91,6 +92,7 @@ public function test_get_items() { /** * @ticket 40365 * @covers ::get_item + * @group ms-required */ public function test_get_item() { wp_set_current_user( self::$superadmin_id ); @@ -112,11 +114,38 @@ public function test_get_item() { $this->assertEquals( 1, $data['public'] ); } + /** + * @ticket 40365 + * @covers ::get_items + * @group ms-excluded + */ + public function test_get_items_no_ms() { + wp_set_current_user( self::$superadmin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::get_item + * @group ms-excluded + */ + public function test_get_item_no_ms() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/1' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); + } + /** * An unknown ID is a 404, not an empty site. * * @ticket 40365 * @covers ::get_item + * @group ms-required */ public function test_get_item_invalid_id() { wp_set_current_user( self::$superadmin_id ); @@ -130,6 +159,7 @@ public function test_get_item_invalid_id() { /** * @ticket 40365 * @covers ::create_item + * @group ms-required */ public function test_create_item() { wp_set_current_user( self::$superadmin_id ); @@ -156,6 +186,7 @@ public function test_create_item() { /** * @ticket 40365 * @covers ::update_item + * @group ms-required */ public function test_update_item() { wp_set_current_user( self::$superadmin_id ); @@ -177,9 +208,44 @@ public function test_update_item() { $this->assertEquals( '/incididunt/', get_site( $blog_id )->path ); } + /** + * @ticket 40365 + * @covers ::create_item + * @group ms-excluded + */ + public function test_create_item_no_ms() { + 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', '/tempor/' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::update_item + * @group ms-excluded + */ + public function test_update_item_no_ms() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/1' ); + $request->set_param( 'path', '/incididunt/' ); + $request->set_param( 'mature', 1 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); + } + /** * @ticket 40365 * @covers ::delete_item + * @group ms-required */ public function test_delete_item() { wp_set_current_user( self::$superadmin_id ); @@ -200,11 +266,28 @@ public function test_delete_item() { $this->assertNull( get_site( $blog_id ) ); } + /** + * @ticket 40365 + * @covers ::delete_item + * @group ms-excluded + */ + public function test_delete_item_no_ms() { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/1' ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); + } + /** * Deleting a site drops its tables. * * @ticket 40365 * @covers ::delete_item + * @group ms-required */ public function test_delete_item_uninitializes_the_site() { wp_set_current_user( self::$superadmin_id ); @@ -227,6 +310,7 @@ public function test_delete_item_uninitializes_the_site() { * * @ticket 40365 * @covers ::delete_item + * @group ms-required */ public function test_delete_item_requires_force() { wp_set_current_user( self::$superadmin_id ); @@ -245,6 +329,7 @@ public function test_delete_item_requires_force() { * * @ticket 40365 * @covers ::delete_item + * @group ms-required */ public function test_delete_main_site_is_not_allowed() { wp_set_current_user( self::$superadmin_id ); @@ -263,6 +348,7 @@ public function test_delete_main_site_is_not_allowed() { /** * @ticket 40365 * @covers ::prepare_item_for_response + * @group ms-required */ public function test_prepare_item() { wp_set_current_user( self::$superadmin_id ); @@ -343,6 +429,7 @@ public function test_get_item_schema() { * * @ticket 40365 * @covers ::prepare_item_for_response + * @group ms-required */ public function test_get_item_uses_the_schema_types() { wp_set_current_user( self::$superadmin_id ); @@ -368,6 +455,7 @@ public function test_get_item_uses_the_schema_types() { * * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_filter_by_status() { wp_set_current_user( self::$superadmin_id ); @@ -400,6 +488,7 @@ public function test_get_items_filter_by_status() { * * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_without_status_filter_returns_every_site() { wp_set_current_user( self::$superadmin_id ); @@ -419,6 +508,7 @@ public function test_get_items_without_status_filter_returns_every_site() { * * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_filter_by_lang_id() { wp_set_current_user( self::$superadmin_id ); @@ -449,6 +539,7 @@ public function test_get_items_filter_by_lang_id() { * * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_filter_by_registration_date() { wp_set_current_user( self::$superadmin_id ); @@ -479,6 +570,7 @@ public function test_get_items_filter_by_registration_date() { * * @ticket 40365 * @covers ::create_item + * @group ms-required */ public function test_create_item_sets_the_title_and_the_administrator() { wp_set_current_user( self::$superadmin_id ); @@ -512,6 +604,7 @@ public function test_create_item_sets_the_title_and_the_administrator() { * * @ticket 40365 * @covers ::create_item + * @group ms-required */ public function test_create_item_rejects_an_unknown_user_id() { wp_set_current_user( self::$superadmin_id ); @@ -558,6 +651,7 @@ public function test_update_item_does_not_accept_the_creation_fields() { * * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_me_filter_reports_the_filtered_total() { $blog_ids = self::factory()->blog->create_many( 3 ); @@ -590,6 +684,7 @@ public function test_get_items_me_filter_reports_the_filtered_total() { * * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_filter_user_without_sites() { wp_set_current_user( self::$superadmin_id ); @@ -612,6 +707,7 @@ public function test_get_items_filter_user_without_sites() { * * @ticket 40365 * @covers ::prepare_links + * @group ms-required */ public function test_get_item_has_links() { wp_set_current_user( self::$superadmin_id ); @@ -634,6 +730,7 @@ public function test_get_item_has_links() { * * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_does_not_switch_blogs_for_table_columns() { wp_set_current_user( self::$superadmin_id ); @@ -671,6 +768,7 @@ public function test_get_items_does_not_switch_blogs_for_table_columns() { * * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_head_request_returns_no_body() { wp_set_current_user( self::$superadmin_id ); @@ -692,6 +790,7 @@ public function test_head_request_returns_no_body() { * * @ticket 40365 * @covers ::get_item + * @group ms-required */ public function test_head_request_on_a_single_site_returns_no_body() { wp_set_current_user( self::$superadmin_id ); @@ -721,6 +820,7 @@ public function test_head_request_on_a_single_site_returns_no_body() { * * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_are_ordered_ascending() { wp_set_current_user( self::$superadmin_id ); @@ -740,6 +840,7 @@ public function test_get_items_are_ordered_ascending() { * * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_orderby_id_list_without_a_list() { wp_set_current_user( self::$superadmin_id ); @@ -760,6 +861,7 @@ public function test_get_items_orderby_id_list_without_a_list() { * * @ticket 40365 * @covers ::update_item + * @group ms-required */ public function test_update_item_does_not_slash_the_stored_data() { wp_set_current_user( self::$superadmin_id ); @@ -780,6 +882,7 @@ public function test_update_item_does_not_slash_the_stored_data() { * * @ticket 40365 * @covers ::create_item + * @group ms-required */ public function test_create_item_stores_the_status_fields() { wp_set_current_user( self::$superadmin_id ); @@ -808,6 +911,7 @@ public function test_create_item_stores_the_status_fields() { * * @ticket 40365 * @covers ::update_item + * @group ms-required */ public function test_update_item_keeps_fields_that_were_not_sent() { wp_set_current_user( self::$superadmin_id ); @@ -833,6 +937,7 @@ public function test_update_item_keeps_fields_that_were_not_sent() { * * @ticket 40365 * @covers ::update_item + * @group ms-required */ public function test_update_item_keeps_the_domain() { wp_set_current_user( self::$superadmin_id ); @@ -908,6 +1013,7 @@ public function test_invalid_user_input() { /** * @ticket 40365 * @covers ::get_user_site_ids + * @group ms-required */ public function test_valid_user_input() { @@ -924,6 +1030,7 @@ public function test_valid_user_input() { /** * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_filter_user() { wp_set_current_user( self::$superadmin_id ); @@ -946,6 +1053,7 @@ public function test_get_items_filter_user() { /** * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_me_filter_user() { @@ -968,6 +1076,7 @@ public function test_get_items_me_filter_user() { /** * @ticket 40365 * @covers ::get_items_permissions_check + * @group ms-required */ public function test_get_items_filter_user_no_access() { @@ -989,6 +1098,7 @@ public function test_get_items_filter_user_no_access() { /** * @ticket 40365 * @covers ::get_items + * @group ms-required */ public function test_get_items_filter_with_includes_user() { wp_set_current_user( self::$superadmin_id ); From 1a4ddf81ffd06b126da9cd6482df2f0219a14ec6 Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Fri, 4 Sep 2026 12:23:28 +0100 Subject: [PATCH 06/38] Enhance site deletion process to support force deletion and user removal --- .../class-wp-rest-sites-controller.php | 47 +++++++++++++------ .../tests/rest-api/wpRestSitesController.php | 21 +++++++-- 2 files changed, 50 insertions(+), 18 deletions(-) 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 09903dc2b96cb..9665f05309c22 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 @@ -100,7 +100,7 @@ public function register_routes() { 'force' => array( 'type' => 'boolean', 'default' => false, - 'description' => __( 'Required to be true, as sites do not support trashing.' ), + 'description' => __( 'Whether to set site to deleted and force deletion.' ), ), ), ), @@ -694,24 +694,15 @@ public function delete_item_permissions_check( $request ) { * @return WP_Error|WP_REST_Response Response object on success, or error object on failure. */ public function delete_item( $request ) { - $site = $this->get_site( $request['id'] ); + $blog_id = (int) $request['id']; + $site = $this->get_site( $blog_id ); if ( is_wp_error( $site ) ) { return $site; } - if ( ! (bool) $request['force'] ) { - return new WP_Error( - 'rest_trash_not_supported', - /* translators: %s: force=true */ - sprintf( __( "Sites do not support trashing. Set '%s' to delete." ), 'force=true' ), - array( 'status' => 501 ) - ); - } - $request->set_param( 'context', 'edit' ); $previous = $this->prepare_item_for_response( $site, $request ); - $result = wp_delete_site( $request['id'] ); $response = new WP_REST_Response(); $response->set_data( @@ -721,10 +712,36 @@ public function delete_item( $request ) { ) ); - if ( is_wp_error( $result ) ) { - $result->add_data( array( 'status' => 500 ) ); + if ( ! (bool) $request['force'] ) { + /** This action is documented in wp-includes/ms-site.php */ + do_action_deprecated( 'delete_blog', array( $blog_id, false ), '5.1.0' ); - return $result; + $users = get_users( + array( + 'blog_id' => $blog_id, + 'fields' => 'ids', + ) + ); + + // Remove users from this blog. + if ( ! empty( $users ) ) { + foreach ( $users as $user_id ) { + remove_user_from_blog( $user_id, $blog_id ); + } + } + + update_blog_status( $blog_id, 'deleted', 1 ); + + /** This action is documented in wp-includes/ms-site.php */ + do_action_deprecated( 'deleted_blog', array( $blog_id, false ), '5.1.0' ); + } else { + + $result = wp_delete_site( $request['id'] ); + if ( is_wp_error( $result ) ) { + $result->add_data( array( 'status' => 500 ) ); + + return $result; + } } /** diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 789eb40852f2c..3291c3e9803ce 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -306,7 +306,8 @@ public function test_delete_item_uninitializes_the_site() { } /** - * Sites have no trash, so deleting has to be explicit. + * Deleting without force marks the site as deleted and removes its users, + * but does not drop the site. * * @ticket 40365 * @covers ::delete_item @@ -316,12 +317,26 @@ public function test_delete_item_requires_force() { wp_set_current_user( self::$superadmin_id ); $blog_id = self::factory()->blog->create( array( 'path' => '/consectetur/' ) ); + $user_id = self::factory()->user->create(); + add_user_to_blog( $blog_id, $user_id, 'author' ); + + $this->assertTrue( is_user_member_of_blog( $user_id, $blog_id ) ); $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501 ); - $this->assertNotNull( get_site( $blog_id ) ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertTrue( $data['deleted'] ); + + // The site still exists but is flagged as deleted. + $site = get_site( $blog_id ); + $this->assertNotNull( $site ); + $this->assertEquals( '1', $site->deleted ); + + // Its users have been removed from the blog. + $this->assertFalse( is_user_member_of_blog( $user_id, $blog_id ) ); } /** From 13c56eee9d64c04f4b970681c291808b920edfe5 Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Fri, 4 Sep 2026 15:16:42 +0100 Subject: [PATCH 07/38] Tests: Add validation for existing domain and path when creating or updating sites --- .../class-wp-rest-sites-controller.php | 55 ++++++++ .../tests/rest-api/wpRestSitesController.php | 122 ++++++++++++++++++ 2 files changed, 177 insertions(+) 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 09903dc2b96cb..fc644d33cc23b 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 @@ -503,6 +503,8 @@ public function create_item( $request ) { return new WP_Error( 'rest_site_failed_create', __( 'Creating site failed.' ), array( 'status' => 500 ) ); } + wp_cache_set_sites_last_changed(); + $site = get_site( $site_id ); /** @@ -970,6 +972,11 @@ protected function prepare_item_for_database( $request ) { $prepared_site['domain'] = $request['domain']; } + $domain_check = $this->check_domain_is_available( $prepared_site, $request ); + if ( is_wp_error( $domain_check ) ) { + return $domain_check; + } + /** * Filters a site after it is prepared for the database. * @@ -983,6 +990,53 @@ protected function prepare_item_for_database( $request ) { return apply_filters( 'rest_preprocess_site', $prepared_site, $request ); } + /** + * Checks that the domain and path for a prepared site are not already in use. + * + * When creating a site, the domain and path must not belong to any existing + * site. When updating a site, they may only belong to the site being updated. + * + * @since 7.2.0 + * + * @param array $prepared_site The prepared site data for `wp_insert_site()`. + * @param WP_REST_Request $request The current request. + * @return true|WP_Error True if the domain and path are available, WP_Error otherwise. + */ + protected function check_domain_is_available( $prepared_site, $request ) { + $id = (int) $request['id']; + $domain = isset( $prepared_site['domain'] ) ? $prepared_site['domain'] : ''; + $path = isset( $prepared_site['path'] ) ? $prepared_site['path'] : '/'; + $network_id = isset( $prepared_site['network_id'] ) ? $prepared_site['network_id'] : get_current_network_id(); + + if ( ! empty( $id ) ) { + // Updating a site: fall back to the current values for anything the request left out. + $current_site = $this->get_site( $id ); + + if ( is_wp_error( $current_site ) ) { + return $current_site; + } + + if ( ! isset( $prepared_site['domain'] ) ) { + $domain = $current_site->domain; + } + if ( ! isset( $prepared_site['path'] ) ) { + $path = $current_site->path; + } + if ( ! isset( $prepared_site['network_id'] ) ) { + $network_id = (int) $current_site->network_id; + } + } + + $existing_site_id = domain_exists( $domain, $path, $network_id ); + + // The domain and path may only belong to the site being updated. + if ( $existing_site_id && (int) $existing_site_id !== $id ) { + return new WP_Error( 'rest_site_taken', __( 'Sorry, that site already exists!' ), array( 'status' => 400 ) ); + } + + return true; + } + /** * Retrieves the site's schema, conforming to JSON Schema. * @@ -1010,6 +1064,7 @@ public function get_item_schema() { 'description' => __( 'The site\'s network ID. Default is the current network ID.' ), 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), + 'default' => get_current_network_id(), ), 'domain' => array( 'description' => __( 'Site domain.' ), diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 789eb40852f2c..e2de5727d6d55 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -183,6 +183,128 @@ public function test_create_item() { $this->assertEquals( '/tempor/', $site->path ); } + /** + * @ticket 40365 + * @covers ::create_item + * @group ms-required + */ + public function test_create_item_rejects_an_existing_domain_and_path() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/existing/', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/existing/' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_site_taken', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::create_item + * @group ms-required + */ + public function test_create_item_allows_an_existing_domain_and_path_on_a_different_network() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/shared/', + ) + ); + + $network_id = self::factory()->network->create( + array( + 'domain' => 'other-network.example.org', + 'path' => '/', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/shared/' ); + $request->set_param( 'network', $network_id ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( WP_TESTS_DOMAIN, $data['domain'] ); + $this->assertEquals( '/shared/', $data['path'] ); + $this->assertEquals( $network_id, $data['network'] ); + } + + /** + * @ticket 40365 + * @covers ::update_item + * @group ms-required + */ + public function test_update_item_rejects_another_sites_domain_and_path() { + wp_set_current_user( self::$superadmin_id ); + + self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/taken/', + ) + ); + $blog_id = self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/free/', + ) + ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/taken/' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_site_taken', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::update_item + * @group ms-required + */ + public function test_update_item_allows_a_site_to_keep_its_own_domain_and_path() { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/keep/', + ) + ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/keep/' ); + $request->set_param( 'mature', 1 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEquals( '/keep/', $data['path'] ); + $this->assertEquals( 1, $data['mature'] ); + } + /** * @ticket 40365 * @covers ::update_item From 8fe0b65a364950f6136424c4cf7f2b3a558755cc Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Fri, 4 Sep 2026 15:21:20 +0100 Subject: [PATCH 08/38] Remove redundant cache update call in site creation process --- .../rest-api/endpoints/class-wp-rest-sites-controller.php | 2 -- 1 file changed, 2 deletions(-) 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 fc644d33cc23b..019439e3f49d4 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 @@ -503,8 +503,6 @@ public function create_item( $request ) { return new WP_Error( 'rest_site_failed_create', __( 'Creating site failed.' ), array( 'status' => 500 ) ); } - wp_cache_set_sites_last_changed(); - $site = get_site( $site_id ); /** From e0801aa935dcf5873d9e494898cf25634f4deda0 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Fri, 4 Sep 2026 21:52:56 +0100 Subject: [PATCH 09/38] REST API: Validate domain and path on the Sites Controller Add format validation for the `domain` and `path` parameters in WP_REST_Sites_Controller so malformed values are rejected with a clean 400 rest_invalid_param instead of failing later inside wp_insert_site()/ wp_update_site() (which surfaces as a 500). domain must be a valid hostname or bare IPv4/IPv6 address, optionally followed by a port (bracketed IPv6 is intentionally not supported, so IPv6 addresses can't carry a port). path must start and end with a forward slash and be made up of characters valid in a URL path segment. Ticket #40365. --- .../class-wp-rest-sites-controller.php | 162 ++++++++++++++ .../tests/rest-api/wpRestSitesController.php | 201 ++++++++++++++++++ 2 files changed, 363 insertions(+) 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 09903dc2b96cb..7c81823245ddd 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 @@ -983,6 +983,162 @@ protected function prepare_item_for_database( $request ) { return apply_filters( 'rest_preprocess_site', $prepared_site, $request ); } + /** + * Validates that the `domain` parameter is a valid hostname or IP address. + * + * @since 7.2.0 + * + * @param mixed $value Value of the `domain` parameter. + * @param WP_REST_Request $request The current request. + * @param string $param The parameter name. + * @return true|WP_Error True if the value is valid, otherwise a WP_Error object. + */ + public function validate_domain( $value, $request, $param ) { + $valid = rest_validate_request_arg( $value, $request, $param ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + if ( $this->is_valid_domain( $value ) ) { + return true; + } + + return new WP_Error( + 'rest_invalid_param', + __( 'domain must be a valid hostname or IP address (IPv4 or IPv6), optionally followed by a port. Bracketed IPv6 addresses are not supported.' ), + array( + 'status' => 400, + 'param' => $param, + ) + ); + } + + /** + * Checks whether a string is a valid domain: a hostname or bare IP address + * (IPv4 or IPv6, unbracketed), optionally followed by a port number. + * Brackets are not supported, so an IPv6 address can't carry a port. + * + * @since 7.2.0 + * + * @param string $domain The domain to check. + * @return bool True if `$domain` is a valid domain, otherwise false. + */ + private function is_valid_domain( $domain ) { + if ( ! is_string( $domain ) || '' === $domain ) { + return false; + } + + // Bare IPv4 or IPv6 address, no port. + if ( $this->is_ip_address_candidate( $domain ) && rest_is_ip_address( $domain ) ) { + return true; + } + + // hostname[:port] or ipv4[:port]. A second colon means this isn't a + // single port suffix but an (invalid, since it failed the check above) + // unbracketed IPv6 address, so only split off a port when there's exactly one colon. + $host = $domain; + $port = null; + + if ( 1 === substr_count( $domain, ':' ) ) { + $colon_pos = strpos( $domain, ':' ); + $host = substr( $domain, 0, $colon_pos ); + $port = substr( $domain, $colon_pos + 1 ); + } + + if ( null !== $port && ! $this->is_valid_port( $port ) ) { + return false; + } + + if ( $this->is_ip_address_candidate( $host ) && rest_is_ip_address( $host ) ) { + return true; + } + + return 1 === preg_match( + '/^(?!-)[A-Za-z0-9-]{1,63}(?= 1 && (int) $port <= 65535; + } + + /** + * Checks whether a string is made up only of characters that can appear + * in an IPv4 or IPv6 (including IPv4-mapped) address, i.e. it's safe to + * pass to rest_is_ip_address(). That underlying check parses its input + * as hexadecimal, and PHP emits a warning for any other character, so + * this keeps arbitrary domain input from tripping that on the way in. + * + * @since 7.2.0 + * + * @param string $value The value to check. + * @return bool True if `$value` only contains valid IP address characters, otherwise false. + */ + private function is_ip_address_candidate( $value ) { + return 1 === preg_match( '/^[0-9A-Fa-f:.]+$/', $value ); + } + + /** + * Validates that the `path` parameter is a valid site path. + * + * @since 7.2.0 + * + * @param mixed $value Value of the `path` parameter. + * @param WP_REST_Request $request The current request. + * @param string $param The parameter name. + * @return true|WP_Error True if the value is valid, otherwise a WP_Error object. + */ + public function validate_path( $value, $request, $param ) { + $valid = rest_validate_request_arg( $value, $request, $param ); + if ( is_wp_error( $valid ) ) { + return $valid; + } + + if ( $this->is_valid_path( $value ) ) { + return true; + } + + return new WP_Error( + 'rest_invalid_param', + __( 'path must be a valid site path, starting and ending with a forward slash.' ), + array( + 'status' => 400, + 'param' => $param, + ) + ); + } + + /** + * Checks whether a string is a valid site path: a forward slash, or a + * sequence of `/`-delimited segments each starting and ending with a + * forward slash, made up of characters allowed in a URL path segment + * (RFC 3986 `pchar`). + * + * @since 7.2.0 + * + * @param string $path The path to check. + * @return bool True if `$path` is a valid site path, otherwise false. + */ + private function is_valid_path( $path ) { + if ( ! is_string( $path ) || '' === $path ) { + return false; + } + + return 1 === preg_match( + '#^/(?:(?:[A-Za-z0-9._~!$&\'()*+,;=:@-]|%[0-9A-Fa-f]{2})+/)*$#', + $path + ); + } + /** * Retrieves the site's schema, conforming to JSON Schema. * @@ -1016,12 +1172,18 @@ public function get_item_schema() { 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'default' => '', + 'arg_options' => array( + 'validate_callback' => array( $this, 'validate_domain' ), + ), ), 'path' => array( 'description' => __( 'Site path.' ), 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'default' => '/', + 'arg_options' => array( + 'validate_callback' => array( $this, 'validate_path' ), + ), ), 'registered' => array( 'description' => __( 'When the site was registered, in the site\'s timezone.' ), diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 789eb40852f2c..8694aa62ba3ba 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -955,6 +955,207 @@ public function test_update_item_keeps_the_domain() { $this->assertEquals( 0, get_site( $blog_id )->public ); } + /** + * The `domain` parameter must be a valid hostname or IP address, + * optionally followed by a port, when creating a site. + * + * @ticket 40365 + * @covers ::create_item + * @group ms-required + * @dataProvider data_domain_validation + */ + public function test_create_item_domain_validation( $domain, $is_valid ) { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', $domain ); + $request->set_param( 'path', '/domain-validation/' ); + + $response = rest_get_server()->dispatch( $request ); + + if ( $is_valid ) { + $this->assertEquals( 201, $response->get_status(), "domain '{$domain}' should have been accepted." ); + + $data = $response->get_data(); + $this->assertEquals( $this->normalize_domain_for_storage( $domain ), $data['domain'] ); + } else { + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + } + + /** + * The `domain` parameter must be a valid hostname or IP address, + * optionally followed by a port, when updating a site. + * + * @ticket 40365 + * @covers ::update_item + * @group ms-required + * @dataProvider data_domain_validation + */ + public function test_update_item_domain_validation( $domain, $is_valid ) { + wp_set_current_user( self::$superadmin_id ); + + $blog_id = self::factory()->blog->create( array( 'path' => '/domain-validation-update/' ) ); + $orig_domain = get_site( $blog_id )->domain; + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'domain', $domain ); + + $response = rest_get_server()->dispatch( $request ); + + if ( $is_valid ) { + $this->assertEquals( 200, $response->get_status(), "domain '{$domain}' should have been accepted." ); + + $data = $response->get_data(); + $expected = $this->normalize_domain_for_storage( $domain ); + $this->assertEquals( $expected, $data['domain'] ); + $this->assertEquals( $expected, get_site( $blog_id )->domain ); + } else { + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $this->assertEquals( $orig_domain, get_site( $blog_id )->domain ); + } + } + + /** + * Mirrors the character-stripping done by `wp_normalize_site_data()` so that + * the domain-validation tests assert against what actually ends up stored, + * e.g. bracketed IPv6 literals lose their `[` `]` at that (unrelated) layer. + * + * @param string $domain Domain as submitted to the endpoint. + * @return string Domain as it will be persisted. + */ + private function normalize_domain_for_storage( $domain ) { + return preg_replace( '/[^a-z0-9\-.:]+/i', '', $domain ); + } + + /** + * Data provider for the `domain` format validation tests. + * + * @return array + */ + public function data_domain_validation() { + return array( + 'plain hostname' => array( 'example.org', true ), + 'subdomain' => array( 'sub.example.org', true ), + 'hostname with port' => array( 'example.org:8080', true ), + // The shape produced by wp-admin/network/site-new.php for a subdomain install + // when the network domain itself carries a port, e.g. "blogname.localhost:8889". + 'subdomain of a network domain with a port' => array( 'blogname.localhost:8889', true ), + 'ipv4' => array( '198.51.100.10', true ), + 'ipv4 with port' => array( '198.51.100.10:8080', true ), + 'bare ipv6 loopback, no port' => array( '::1', true ), + 'bare ipv6, no port' => array( '2001:db8::1', true ), + 'bare full-length ipv6, no port' => array( '2001:0db8:0000:0000:0000:0000:0000:0001', true ), + 'bare ipv4-mapped ipv6, no port' => array( '::ffff:192.0.2.1', true ), + // Ambiguous: no brackets to separate a port, so the whole string is + // parsed as a literal (8-group, after :: expansion) IPv6 address. + 'ambiguous unbracketed ipv6 with trailing digits treated as address' => array( '2001:db8::1:8080', true ), + // Bracketed IPv6 is not supported at all: brackets aren't valid + // hostname or bare-IP characters, so any leading `[` is rejected outright. + 'bracketed ipv6 is rejected' => array( '[2001:db8::1]', false ), + 'bracketed ipv6 with port is rejected' => array( '[2001:db8::1]:8080', false ), + 'bracketed ipv6 loopback is rejected' => array( '[::1]', false ), + 'empty brackets are rejected' => array( '[]', false ), + 'unbracketed ipv6-shaped string with a port-like trailing group is rejected' => array( '1:2:3:4:5:6:7:8:9', false ), + 'empty domain' => array( '', false ), + 'domain with a space' => array( 'example org', false ), + 'domain with a scheme' => array( 'http://example.org', false ), + 'leading hyphen label' => array( '-example.org', false ), + 'empty label' => array( 'example..org', false ), + 'port out of range' => array( 'example.org:99999', false ), + 'non numeric port' => array( 'example.org:abc', false ), + ); + } + + /** + * The `path` parameter must be a valid site path when creating a site. + * + * @ticket 40365 + * @covers ::create_item + * @group ms-required + * @dataProvider data_path_validation + */ + public function test_create_item_path_validation( $path, $is_valid ) { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + // A domain distinct from WP_TESTS_DOMAIN, so a root path doesn't collide with the network's main site. + $request->set_param( 'domain', 'path-validation.example.org' ); + $request->set_param( 'path', $path ); + + $response = rest_get_server()->dispatch( $request ); + + if ( $is_valid ) { + $this->assertEquals( 201, $response->get_status(), "path '{$path}' should have been accepted." ); + + $data = $response->get_data(); + $this->assertEquals( $path, $data['path'] ); + } else { + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + } + + /** + * The `path` parameter must be a valid site path when updating a site. + * + * @ticket 40365 + * @covers ::update_item + * @group ms-required + * @dataProvider data_path_validation + */ + public function test_update_item_path_validation( $path, $is_valid ) { + wp_set_current_user( self::$superadmin_id ); + + // A domain distinct from WP_TESTS_DOMAIN, so a root path doesn't collide with the network's main site. + $blog_id = self::factory()->blog->create( + array( + 'domain' => 'path-validation-update.example.org', + 'path' => '/path-validation-update/', + ) + ); + $orig_path = get_site( $blog_id )->path; + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'path', $path ); + + $response = rest_get_server()->dispatch( $request ); + + if ( $is_valid ) { + $this->assertEquals( 200, $response->get_status(), "path '{$path}' should have been accepted." ); + + $data = $response->get_data(); + $this->assertEquals( $path, $data['path'] ); + $this->assertEquals( $path, get_site( $blog_id )->path ); + } else { + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $this->assertEquals( $orig_path, get_site( $blog_id )->path ); + } + } + + /** + * Data provider for the `path` format validation tests. + * + * @return array + */ + public function data_path_validation() { + return array( + 'root path' => array( '/', true ), + 'single segment' => array( '/tempor/', true ), + 'nested segments' => array( '/parent/child/', true ), + 'segment with apostrophe' => array( "/o'brien/", true ), + 'segment with punctuation' => array( '/with-hyphen_and.dot~tilde/', true ), + 'percent encoded segment' => array( '/percent%20encoded/', true ), + 'empty path' => array( '', false ), + 'no leading slash' => array( 'no-leading-slash/', false ), + 'no trailing slash' => array( '/no-trailing-slash', false ), + 'double slash' => array( '//double-slash//', false ), + 'path with a space' => array( '/with space/', false ), + 'path with a query string' => array( '/with?query/', false ), + 'path with a fragment' => array( '/with#fragment/', false ), + 'path with a double quote' => array( '/with"quote/', false ), + ); + } + /** * Site meta is exposed through the endpoint. * From 586589de5191f1f25d8601eb01795be6584217f9 Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Fri, 18 Sep 2026 15:03:40 +0100 Subject: [PATCH 10/38] Apply suggestion from @spacedmonkey --- tests/qunit/fixtures/wp-api-generated.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 503cccd584b2b..99580f63b4e27 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11267,7 +11267,7 @@ mockedApiResponse.Schema = { "force": { "type": "boolean", "default": false, - "description": "Required to be true, as sites do not support trashing.", + "description": "Whether to set site to deleted and force deletion.", "required": false } } From a6430fb25d971a01f60ace20592a39813224e76f Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Fri, 18 Sep 2026 15:27:21 +0100 Subject: [PATCH 11/38] Apply suggestion from @spacedmonkey --- tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php b/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php index 24893503d46c2..601acddea1168 100644 --- a/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php +++ b/tests/phpunit/tests/rest-api/wpRestSiteMetaFields.php @@ -10,7 +10,7 @@ * * @coversDefaultClass WP_REST_Site_Meta_Fields */ -abstract class WP_Test_REST_Site_Meta_Fields extends WP_Test_REST_TestCase { +class WP_Test_REST_Site_Meta_Fields extends WP_Test_REST_TestCase { protected static $wp_meta_keys_saved; protected static $site_admins_saved; protected static $site_id; From b178b2f4ed140fd25ac5497eda311dbb4e6b2687 Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Mon, 28 Sep 2026 07:01:57 +0200 Subject: [PATCH 12/38] Update src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> --- .../rest-api/endpoints/class-wp-rest-sites-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a4b826dc99966..7141e4345b335 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 @@ -176,7 +176,7 @@ public function get_items( $request ) { /* * This array defines mappings between public API query parameters whose - * values are accepted as-passed, and their internal WP_Query parameter + * values are accepted as-passed, and their internal WP_Site_Query parameter * name equivalents (some are the same). Only values which are also * present in $registered will be set. */ From 8b519ffc36d2aa4ba54e6a57202544392b90c562 Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Mon, 28 Sep 2026 07:02:50 +0200 Subject: [PATCH 13/38] Update src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> --- .../rest-api/endpoints/class-wp-rest-sites-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7141e4345b335..02f5f88adfe1d 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 @@ -409,7 +409,7 @@ public function get_item_permissions_check( $request ) { } if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'manage_sites' ) ) { - return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to view sites.' ), array( 'status' => rest_authorization_required_code() ) ); } return true; From 797e119cfd918810cafb2c146c2b2ed1056323f4 Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Mon, 28 Sep 2026 07:03:23 +0200 Subject: [PATCH 14/38] Update src/wp-includes/rest-api/endpoints/class-wp-rest-sites-controller.php Co-authored-by: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> --- .../rest-api/endpoints/class-wp-rest-sites-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 02f5f88adfe1d..7c604035e34e4 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 @@ -674,7 +674,7 @@ public function delete_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } - if ( ! current_user_can( 'delete_sites' ) ) { + if ( ! current_user_can( 'delete_site', $request['id'] ) ) { return new WP_Error( 'rest_cannot_delete', __( 'Sorry, you are not allowed to delete this site.' ), array( 'status' => rest_authorization_required_code() ) ); } From 4aa194739e5ff086225e56ca433a065c4be88f7c Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Mon, 28 Sep 2026 22:30:58 +0100 Subject: [PATCH 15/38] Multisite: Require delete_sites capability to delete a site via REST, refine sites permission checks. `WP_REST_Sites_Controller::delete_item_permissions_check()` only checked the `delete_site` meta capability, which core maps unconditionally to `manage_options` and is not scoped to the target site. This let any per-site Administrator delete an arbitrary site network-wide, unlike core's own network admin Sites screen and this controller's own create/update checks, which correctly require the super-admin-only `create_sites`/`manage_sites` capabilities. - Require `delete_sites` (in addition to the existing `delete_site` meta cap check) before a site can be deleted, matching the layering `wp-admin/network/sites.php` already uses. - Return a specific `rest_cannot_create` error from `create_item_permissions_check()` instead of a bare boolean, matching `WP_REST_Posts_Controller` and this class's own `rest_cannot_edit`/ `rest_cannot_delete` errors. - Extract `check_multisite_support()`, `check_delete_permission()`, and reuse `check_edit_permission()` to de-duplicate capability checks that were repeated across every permission-check method. - `get_item_permissions_check()`: a site's default/view-context data is now readable by members of that site (or a super admin), not the general public; other contexts still require `manage_sites`. - Add regression tests for the capability gap, the multisite-check ordering, and the view/edit context permission boundaries. Props spacedmonkey. Fixes #40365. Co-Authored-By: Claude Sonnet 5 --- .../class-wp-rest-sites-controller.php | 107 ++++--- .../tests/rest-api/wpRestSitesController.php | 288 ++++++++++++++++++ 2 files changed, 347 insertions(+), 48 deletions(-) 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 7c604035e34e4..9f330f8457030 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 @@ -118,16 +118,12 @@ public function register_routes() { * @return WP_Error|bool True if the request has read access, error object otherwise. */ public function get_items_permissions_check( $request ) { - - if ( 0 === get_current_user_id() ) { - return false; - } - - if ( ! is_multisite() ) { - return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + $multisite_support = $this->check_multisite_support(); + if ( is_wp_error( $multisite_support ) ) { + return $multisite_support; } - if ( current_user_can( 'manage_sites' ) ) { + if ( $this->check_edit_permission() ) { return true; } @@ -395,20 +391,23 @@ public function get_user_site_ids( $user_id ) { * @return WP_Error|bool True if the request has read access for the item, error object otherwise. */ public function get_item_permissions_check( $request ) { + $multisite_support = $this->check_multisite_support(); + if ( is_wp_error( $multisite_support ) ) { + return $multisite_support; + } + $site = $this->get_site( $request['id'] ); if ( is_wp_error( $site ) ) { return $site; } - if ( 0 === get_current_user_id() ) { - return false; - } + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - if ( ! is_multisite() ) { - return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + if ( 'view' === $context && is_user_member_of_blog( get_current_user_id(), (int) $site->blog_id ) ) { + return true; } - if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'manage_sites' ) ) { + if ( ! $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() ) ); } @@ -444,15 +443,16 @@ public function get_item( $request ) { * @return WP_Error|bool True if the request has access to create items, error object otherwise. */ public function create_item_permissions_check( $request ) { - if ( 0 === get_current_user_id() ) { - return false; + $multisite_support = $this->check_multisite_support(); + if ( is_wp_error( $multisite_support ) ) { + return $multisite_support; } - if ( ! is_multisite() ) { - return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); + 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() ) ); } - return current_user_can( 'create_sites' ); + return true; } /** @@ -542,8 +542,7 @@ public function create_item( $request ) { return $fields_update; } - $context = current_user_can( 'manage_sites' ) ? 'edit' : 'view'; - + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $request->set_param( 'context', $context ); $response = $this->prepare_item_for_response( $site, $request ); @@ -564,20 +563,17 @@ public function create_item( $request ) { * @return WP_Error|bool True if the request has access to update the item, error object otherwise. */ public function update_item_permissions_check( $request ) { + $multisite_support = $this->check_multisite_support(); + if ( is_wp_error( $multisite_support ) ) { + return $multisite_support; + } + $site = $this->get_site( $request['id'] ); if ( is_wp_error( $site ) ) { return $site; } - if ( 0 === get_current_user_id() ) { - return false; - } - - if ( ! is_multisite() ) { - return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); - } - - if ( ! $this->check_edit_permission( $site ) ) { + 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() ) ); } @@ -661,20 +657,17 @@ public function update_item( $request ) { * @return WP_Error|bool True if the request has access to delete the item, error object otherwise. */ public function delete_item_permissions_check( $request ) { + $multisite_support = $this->check_multisite_support(); + if ( is_wp_error( $multisite_support ) ) { + return $multisite_support; + } + $site = $this->get_site( $request['id'] ); if ( is_wp_error( $site ) ) { return $site; } - if ( 0 === (int) get_current_user_id() ) { - return false; - } - - if ( ! is_multisite() ) { - return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); - } - - if ( ! current_user_can( 'delete_site', $request['id'] ) ) { + if ( ! $this->check_delete_permission( $site ) ) { return new WP_Error( 'rest_cannot_delete', __( 'Sorry, you are not allowed to delete this site.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -730,7 +723,7 @@ public function delete_item( $request ) { } } - update_blog_status( $blog_id, 'deleted', 1 ); + update_blog_status( $blog_id, 'deleted', '1' ); /** This action is documented in wp-includes/ms-site.php */ do_action_deprecated( 'deleted_blog', array( $blog_id, false ), '5.1.0' ); @@ -1546,22 +1539,40 @@ public function get_collection_params() { } /** - * Checks if a site can be edited or deleted. + * Checks if the current user can edit sites. + * + * @since 7.2.0 + * + * @return bool Whether the current user can edit sites. + */ + protected function check_edit_permission() { + return current_user_can( 'manage_sites' ); + } + + /** + * Checks if a site can be deleted. * * @since 7.2.0 * * @param object $site Site object. - * @return bool Whether the site can be edited or deleted. + * @return bool Whether the site can be deleted. */ - protected function check_edit_permission( $site ) { - if ( 0 === (int) get_current_user_id() ) { - return false; - } + protected function check_delete_permission( $site ) { + return current_user_can( 'delete_sites' ) && current_user_can( 'delete_site', $site->blog_id ); + } + /** + * Checks that multisite is enabled, as this controller requires it. + * + * @since 7.2.0 + * + * @return true|WP_Error True if multisite is enabled, WP_Error otherwise. + */ + protected function check_multisite_support() { if ( ! is_multisite() ) { - return false; + return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } - return current_user_can( 'manage_sites' ); + return true; } } diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 621e534a9f86f..a274a4b3a0e93 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -126,6 +126,52 @@ public function test_get_items_no_ms() { $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); } + /** + * The multisite check runs before the logged-in check, so a logged-out + * request against a single-site install reports the environment problem, + * not a generic authentication error. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-excluded + */ + public function test_get_items_no_ms_when_logged_out() { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); + } + + /** + * A logged-out request with no `user` filter has neither `manage_sites` + * nor a matching own-user filter, so it's still forbidden. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + */ + public function test_get_items_forbidden_when_logged_out() { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); + } + + /** + * The collection is intentionally readable while logged out, as long + * as the request is scoped to "my sites" via the `user` filter. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + */ + public function test_get_items_me_filter_is_readable_when_logged_out() { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'user', 'me' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + /** * @ticket 40365 * @covers ::get_item @@ -140,6 +186,133 @@ public function test_get_item_no_ms() { $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); } + /** + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-excluded + */ + public function test_get_item_no_ms_when_logged_out() { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/1' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); + } + + /** + * A logged-out request can't be a site member and lacks `manage_sites`, + * so it's forbidden even in the default view context. + * + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-required + */ + public function test_get_item_forbidden_when_logged_out() { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/1' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); + } + + /** + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-required + */ + public function test_get_item_edit_context_forbidden_when_logged_out() { + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/1' ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); + } + + /** + * A member of the site (even without `manage_sites`) can view it in + * the default view context. + * + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-required + */ + public function test_get_item_view_context_allowed_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' ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * A logged-in user who is neither a super admin nor a member of the + * site is forbidden from viewing it, even in the default view context. + * + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-required + */ + public function test_get_item_view_context_forbidden_for_non_member() { + $blog_id = self::factory()->blog->create(); + $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + /** + * Site membership only grants the default view context; edit context + * still requires `manage_sites`. + * + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-required + */ + public function test_get_item_edit_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' ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + /** + * A logged-in user who is neither a super admin nor a member of the + * site is still forbidden from viewing it in edit context. + * + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-required + */ + public function test_get_item_edit_context_forbidden_for_non_member() { + $blog_id = self::factory()->blog->create(); + $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + /** * An unknown ID is a 404, not an empty site. * @@ -347,6 +520,36 @@ public function test_create_item_no_ms() { $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); } + /** + * @ticket 40365 + * @covers ::create_item_permissions_check + * @group ms-excluded + */ + public function test_create_item_no_ms_when_logged_out() { + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/tempor/' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::create_item_permissions_check + * @group ms-required + */ + public function test_create_item_requires_being_logged_in() { + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/tempor/' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_create', $response, 401 ); + } + /** * @ticket 40365 * @covers ::update_item @@ -364,6 +567,37 @@ public function test_update_item_no_ms() { $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); } + /** + * @ticket 40365 + * @covers ::update_item_permissions_check + * @group ms-excluded + */ + public function test_update_item_no_ms_when_logged_out() { + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/1' ); + $request->set_param( 'path', '/incididunt/' ); + $request->set_param( 'mature', 1 ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::update_item_permissions_check + * @group ms-required + */ + public function test_update_item_requires_being_logged_in() { + $blog_id = self::factory()->blog->create(); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'path', '/incididunt/' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); + } + /** * @ticket 40365 * @covers ::delete_item @@ -404,6 +638,37 @@ public function test_delete_item_no_ms() { $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); } + /** + * @ticket 40365 + * @covers ::delete_item_permissions_check + * @group ms-excluded + */ + public function test_delete_item_no_ms_when_logged_out() { + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/1' ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_multisite_not_installed', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::delete_item_permissions_check + * @group ms-required + */ + public function test_delete_item_requires_being_logged_in() { + $blog_id = self::factory()->blog->create(); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_delete', $response, 401 ); + $this->assertNotNull( get_site( $blog_id ) ); + } + /** * Deleting a site drops its tables. * @@ -482,6 +747,29 @@ public function test_delete_main_site_is_not_allowed() { $this->assertNotNull( get_site( $main_site_id ) ); } + /** + * A regular site administrator does not have the network-level + * capability required to delete a site. + * + * @ticket 40365 + * @covers ::delete_item_permissions_check + * @group ms-required + */ + public function test_delete_item_requires_delete_sites_cap() { + $blog_id = self::factory()->blog->create(); + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_delete', $response, rest_authorization_required_code() ); + $this->assertNotNull( get_site( $blog_id ) ); + } + /** * @ticket 40365 * @covers ::prepare_item_for_response From 1a1c422304ba0d2f1b2755b11dcf6bf2bbf35737 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Mon, 28 Sep 2026 22:39:20 +0100 Subject: [PATCH 16/38] Tests: Fix WordPress coding standards violations in wpRestSitesController.php. phpcs flagged 34 WordPress.Arrays.MultipleStatementAlignment.DoubleArrowNotAligned violations across the new test methods, auto-fixed via phpcbf. No behavioral change. Co-Authored-By: Claude Sonnet 5 --- .../tests/rest-api/wpRestSitesController.php | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index a274a4b3a0e93..f4c67c056802f 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -1460,35 +1460,35 @@ private function normalize_domain_for_storage( $domain ) { */ public function data_domain_validation() { return array( - 'plain hostname' => array( 'example.org', true ), - 'subdomain' => array( 'sub.example.org', true ), - 'hostname with port' => array( 'example.org:8080', true ), + 'plain hostname' => array( 'example.org', true ), + 'subdomain' => array( 'sub.example.org', true ), + 'hostname with port' => array( 'example.org:8080', true ), // The shape produced by wp-admin/network/site-new.php for a subdomain install // when the network domain itself carries a port, e.g. "blogname.localhost:8889". 'subdomain of a network domain with a port' => array( 'blogname.localhost:8889', true ), - 'ipv4' => array( '198.51.100.10', true ), - 'ipv4 with port' => array( '198.51.100.10:8080', true ), - 'bare ipv6 loopback, no port' => array( '::1', true ), - 'bare ipv6, no port' => array( '2001:db8::1', true ), - 'bare full-length ipv6, no port' => array( '2001:0db8:0000:0000:0000:0000:0000:0001', true ), - 'bare ipv4-mapped ipv6, no port' => array( '::ffff:192.0.2.1', true ), + 'ipv4' => array( '198.51.100.10', true ), + 'ipv4 with port' => array( '198.51.100.10:8080', true ), + 'bare ipv6 loopback, no port' => array( '::1', true ), + 'bare ipv6, no port' => array( '2001:db8::1', true ), + 'bare full-length ipv6, no port' => array( '2001:0db8:0000:0000:0000:0000:0000:0001', true ), + 'bare ipv4-mapped ipv6, no port' => array( '::ffff:192.0.2.1', true ), // Ambiguous: no brackets to separate a port, so the whole string is // parsed as a literal (8-group, after :: expansion) IPv6 address. 'ambiguous unbracketed ipv6 with trailing digits treated as address' => array( '2001:db8::1:8080', true ), // Bracketed IPv6 is not supported at all: brackets aren't valid // hostname or bare-IP characters, so any leading `[` is rejected outright. - 'bracketed ipv6 is rejected' => array( '[2001:db8::1]', false ), - 'bracketed ipv6 with port is rejected' => array( '[2001:db8::1]:8080', false ), - 'bracketed ipv6 loopback is rejected' => array( '[::1]', false ), - 'empty brackets are rejected' => array( '[]', false ), + 'bracketed ipv6 is rejected' => array( '[2001:db8::1]', false ), + 'bracketed ipv6 with port is rejected' => array( '[2001:db8::1]:8080', false ), + 'bracketed ipv6 loopback is rejected' => array( '[::1]', false ), + 'empty brackets are rejected' => array( '[]', false ), 'unbracketed ipv6-shaped string with a port-like trailing group is rejected' => array( '1:2:3:4:5:6:7:8:9', false ), - 'empty domain' => array( '', false ), - 'domain with a space' => array( 'example org', false ), - 'domain with a scheme' => array( 'http://example.org', false ), - 'leading hyphen label' => array( '-example.org', false ), - 'empty label' => array( 'example..org', false ), - 'port out of range' => array( 'example.org:99999', false ), - 'non numeric port' => array( 'example.org:abc', false ), + 'empty domain' => array( '', false ), + 'domain with a space' => array( 'example org', false ), + 'domain with a scheme' => array( 'http://example.org', false ), + 'leading hyphen label' => array( '-example.org', false ), + 'empty label' => array( 'example..org', false ), + 'port out of range' => array( 'example.org:99999', false ), + 'non numeric port' => array( 'example.org:abc', false ), ); } @@ -1564,20 +1564,20 @@ public function test_update_item_path_validation( $path, $is_valid ) { */ public function data_path_validation() { return array( - 'root path' => array( '/', true ), - 'single segment' => array( '/tempor/', true ), - 'nested segments' => array( '/parent/child/', true ), - 'segment with apostrophe' => array( "/o'brien/", true ), - 'segment with punctuation' => array( '/with-hyphen_and.dot~tilde/', true ), - 'percent encoded segment' => array( '/percent%20encoded/', true ), - 'empty path' => array( '', false ), - 'no leading slash' => array( 'no-leading-slash/', false ), - 'no trailing slash' => array( '/no-trailing-slash', false ), - 'double slash' => array( '//double-slash//', false ), - 'path with a space' => array( '/with space/', false ), - 'path with a query string' => array( '/with?query/', false ), - 'path with a fragment' => array( '/with#fragment/', false ), - 'path with a double quote' => array( '/with"quote/', false ), + 'root path' => array( '/', true ), + 'single segment' => array( '/tempor/', true ), + 'nested segments' => array( '/parent/child/', true ), + 'segment with apostrophe' => array( "/o'brien/", true ), + 'segment with punctuation' => array( '/with-hyphen_and.dot~tilde/', true ), + 'percent encoded segment' => array( '/percent%20encoded/', true ), + 'empty path' => array( '', false ), + 'no leading slash' => array( 'no-leading-slash/', false ), + 'no trailing slash' => array( '/no-trailing-slash', false ), + 'double slash' => array( '//double-slash//', false ), + 'path with a space' => array( '/with space/', false ), + 'path with a query string' => array( '/with?query/', false ), + 'path with a fragment' => array( '/with#fragment/', false ), + 'path with a double quote' => array( '/with"quote/', false ), ); } From 8f76f4f3ef754886db9707bcf453822d60c351a0 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Mon, 28 Sep 2026 22:43:56 +0100 Subject: [PATCH 17/38] Tests: Regenerate wp-api-generated.js fixture to include network arg default. The REST API index schema fixture was missing "default": 1 on the sites endpoint's `network` arg (both the create and update route args), which current_user_can()/get_current_network_id() output but the committed fixture predates. Pre-existing drift on this branch, unrelated to the permission-check changes in this PR, caught by CI's generated-file diff check. Co-Authored-By: Claude Sonnet 5 --- tests/qunit/fixtures/wp-api-generated.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 99580f63b4e27..f54dfc053279d 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11089,6 +11089,7 @@ mockedApiResponse.Schema = { ], "args": { "network": { + "default": 1, "description": "The site's network ID. Default is the current network ID.", "type": "integer", "required": false @@ -11208,6 +11209,7 @@ mockedApiResponse.Schema = { "required": false }, "network": { + "default": 1, "description": "The site's network ID. Default is the current network ID.", "type": "integer", "required": false From 4ef13589720a6956e4ded2264e0d31be3717735e Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Mon, 28 Sep 2026 22:53:43 +0100 Subject: [PATCH 18/38] REST API: Rename check_domain_is_available() to check_url_is_available(). The method checks both the `domain` and `path` of a prepared site, not just the domain, so `check_domain_is_available()` was a misleading name. Renamed to check_url_is_available() (domain + path together form the site's URL) and updated its one call site accordingly. Docblock already described the domain+path behavior correctly and needed no changes; no tests reference the method by name, and the existing domain/path-taken conflict tests (test_create_item_domain_validation, test_update_item_*) continue to pass unchanged since this is a pure rename with no behavior change. See #40365. Co-Authored-By: Claude Sonnet 5 --- .../rest-api/endpoints/class-wp-rest-sites-controller.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 7c604035e34e4..639e9a960891a 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 @@ -987,9 +987,9 @@ protected function prepare_item_for_database( $request ) { $prepared_site['domain'] = $request['domain']; } - $domain_check = $this->check_domain_is_available( $prepared_site, $request ); - if ( is_wp_error( $domain_check ) ) { - return $domain_check; + $url_check = $this->check_url_is_available( $prepared_site, $request ); + if ( is_wp_error( $url_check ) ) { + return $url_check; } /** @@ -1173,7 +1173,7 @@ private function is_valid_path( $path ) { * @param WP_REST_Request $request The current request. * @return true|WP_Error True if the domain and path are available, WP_Error otherwise. */ - protected function check_domain_is_available( $prepared_site, $request ) { + protected function check_url_is_available( $prepared_site, $request ) { $id = (int) $request['id']; $domain = isset( $prepared_site['domain'] ) ? $prepared_site['domain'] : ''; $path = isset( $prepared_site['path'] ) ? $prepared_site['path'] : '/'; From 94b6c0e9463b46424e7000b78994ed7b9b2b1279 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Mon, 28 Sep 2026 23:02:43 +0100 Subject: [PATCH 19/38] Tests: Add unit tests for check_url_is_available(). Adds a get_reflective_method() helper (matching the pattern already used in wpRestUrlDetailsController.php) to directly unit test the renamed protected method, rather than only exercising it indirectly through create_item()/update_item(). Covers, using @dataProvider where the setup is shared: - create: a brand new domain/path is available, an existing one is rejected, and omitting `path` entirely falls back to '/' and still conflicts (test_check_url_is_available_on_create, 3 data sets). - update: keeping the site's own domain/path via the current-site fallback is not a conflict, changing to another site's domain/path is (test_check_url_is_available_on_update, 2 data sets). - the same domain/path is available on a different network (standalone, needs its own network fixture). - an invalid site ID on update propagates get_site()'s WP_Error rather than reaching the domain/path check (standalone, distinct assertion). Co-Authored-By: Claude Sonnet 5 --- .../tests/rest-api/wpRestSitesController.php | 284 +++++++++++++++--- 1 file changed, 250 insertions(+), 34 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 621e534a9f86f..dccd7f019b16a 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -40,6 +40,23 @@ public function set_up() { $this->endpoint = new WP_REST_Sites_Controller(); } + /** + * Get reflective access to a private/protected method on + * the WP_REST_Sites_Controller class. + * + * @param string $method_name Method name for which to gain access. + * @return ReflectionMethod + * @throws ReflectionException Throws an exception if method does not exist. + */ + protected function get_reflective_method( $method_name ) { + $class = new ReflectionClass( WP_REST_Sites_Controller::class ); + $method = $class->getMethod( $method_name ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method; + } + /** * @ticket 40365 * @covers ::register_routes @@ -305,6 +322,205 @@ public function test_update_item_allows_a_site_to_keep_its_own_domain_and_path() $this->assertEquals( 1, $data['mature'] ); } + /** + * Data provider for test_check_url_is_available_on_create(). + * + * Every case runs against the same two fixture sites: one at + * WP_TESTS_DOMAIN . '/check-url-taken/' and one at WP_TESTS_DOMAIN . '/' + * (the root), so a candidate path of `null` (omitted entirely) can + * exercise the "defaults to root" fallback against the second fixture. + * + * @return array[] + */ + public function data_check_url_is_available_on_create() { + return array( + 'a brand new domain and path is available' => array( + 'check-url-is-available.example', + '/', + false, + ), + 'an existing domain and path is rejected' => array( + WP_TESTS_DOMAIN, + '/check-url-taken/', + true, + ), + 'omitting path defaults to root, which is also taken' => array( + WP_TESTS_DOMAIN, + null, + true, + ), + ); + } + + /** + * @ticket 40365 + * @covers ::check_url_is_available + * @group ms-required + * @dataProvider data_check_url_is_available_on_create + * + * @param string $candidate_domain Domain to check. + * @param string|null $candidate_path Path to check, or null to omit the `path` key entirely. + * @param bool $expect_conflict Whether a `rest_site_taken` error is expected. + */ + public function test_check_url_is_available_on_create( $candidate_domain, $candidate_path, $expect_conflict ) { + self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/check-url-taken/', + ) + ); + self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/', + ) + ); + + $prepared_site = array( 'domain' => $candidate_domain ); + if ( null !== $candidate_path ) { + $prepared_site['path'] = $candidate_path; + } + + $method = $this->get_reflective_method( 'check_url_is_available' ); + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + + $result = $method->invoke( $this->endpoint, $prepared_site, $request ); + + if ( $expect_conflict ) { + $this->assertWPError( $result ); + $this->assertSame( 'rest_site_taken', $result->get_error_code() ); + $this->assertSame( 400, $result->get_error_data()['status'] ); + } else { + $this->assertTrue( $result ); + } + } + + /** + * The same domain/path is available on a different network. + * + * @ticket 40365 + * @covers ::check_url_is_available + * @group ms-required + */ + public function test_check_url_is_available_allows_the_same_domain_and_path_on_a_different_network() { + self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/check-url-shared/', + ) + ); + + $other_network_id = self::factory()->network->create( + array( + 'domain' => 'check-url-other-network.example', + 'path' => '/', + ) + ); + + $method = $this->get_reflective_method( 'check_url_is_available' ); + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + + $result = $method->invoke( + $this->endpoint, + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/check-url-shared/', + 'network_id' => $other_network_id, + ), + $request + ); + + $this->assertTrue( $result ); + } + + /** + * Data provider for test_check_url_is_available_on_update(). + * + * Both cases run against the same two fixture sites: the one being + * updated (WP_TESTS_DOMAIN . '/check-url-free/') and another site + * occupying WP_TESTS_DOMAIN . '/check-url-taken-2/'. + * + * @return array[] + */ + public function data_check_url_is_available_on_update() { + return array( + 'keeps its own domain and path via fallback (no conflict)' => array( + array(), + false, + ), + 'changes to another site\'s domain and path (conflict)' => array( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/check-url-taken-2/', + ), + true, + ), + ); + } + + /** + * @ticket 40365 + * @covers ::check_url_is_available + * @group ms-required + * @dataProvider data_check_url_is_available_on_update + * + * @param array $prepared_site Prepared site data to check; empty to test the fallback to the current site's own values. + * @param bool $expect_conflict Whether a `rest_site_taken` error is expected. + */ + public function test_check_url_is_available_on_update( $prepared_site, $expect_conflict ) { + self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/check-url-taken-2/', + ) + ); + $blog_id = self::factory()->blog->create( + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/check-url-free/', + ) + ); + + $method = $this->get_reflective_method( 'check_url_is_available' ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + + $result = $method->invoke( $this->endpoint, $prepared_site, $request ); + + if ( $expect_conflict ) { + $this->assertWPError( $result ); + $this->assertSame( 'rest_site_taken', $result->get_error_code() ); + } else { + $this->assertTrue( $result ); + } + } + + /** + * An invalid site ID on update propagates the WP_Error from get_site() + * rather than proceeding to the domain/path check. + * + * @ticket 40365 + * @covers ::check_url_is_available + * @group ms-required + */ + public function test_check_url_is_available_propagates_an_invalid_id_on_update() { + $method = $this->get_reflective_method( 'check_url_is_available' ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $request->set_param( 'id', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $result = $method->invoke( + $this->endpoint, + array( + 'domain' => WP_TESTS_DOMAIN, + 'path' => '/check-url-invalid-id/', + ), + $request + ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_site_invalid_id', $result->get_error_code() ); + } + /** * @ticket 40365 * @covers ::update_item @@ -1172,35 +1388,35 @@ private function normalize_domain_for_storage( $domain ) { */ public function data_domain_validation() { return array( - 'plain hostname' => array( 'example.org', true ), - 'subdomain' => array( 'sub.example.org', true ), - 'hostname with port' => array( 'example.org:8080', true ), + 'plain hostname' => array( 'example.org', true ), + 'subdomain' => array( 'sub.example.org', true ), + 'hostname with port' => array( 'example.org:8080', true ), // The shape produced by wp-admin/network/site-new.php for a subdomain install // when the network domain itself carries a port, e.g. "blogname.localhost:8889". 'subdomain of a network domain with a port' => array( 'blogname.localhost:8889', true ), - 'ipv4' => array( '198.51.100.10', true ), - 'ipv4 with port' => array( '198.51.100.10:8080', true ), - 'bare ipv6 loopback, no port' => array( '::1', true ), - 'bare ipv6, no port' => array( '2001:db8::1', true ), - 'bare full-length ipv6, no port' => array( '2001:0db8:0000:0000:0000:0000:0000:0001', true ), - 'bare ipv4-mapped ipv6, no port' => array( '::ffff:192.0.2.1', true ), + 'ipv4' => array( '198.51.100.10', true ), + 'ipv4 with port' => array( '198.51.100.10:8080', true ), + 'bare ipv6 loopback, no port' => array( '::1', true ), + 'bare ipv6, no port' => array( '2001:db8::1', true ), + 'bare full-length ipv6, no port' => array( '2001:0db8:0000:0000:0000:0000:0000:0001', true ), + 'bare ipv4-mapped ipv6, no port' => array( '::ffff:192.0.2.1', true ), // Ambiguous: no brackets to separate a port, so the whole string is // parsed as a literal (8-group, after :: expansion) IPv6 address. 'ambiguous unbracketed ipv6 with trailing digits treated as address' => array( '2001:db8::1:8080', true ), // Bracketed IPv6 is not supported at all: brackets aren't valid // hostname or bare-IP characters, so any leading `[` is rejected outright. - 'bracketed ipv6 is rejected' => array( '[2001:db8::1]', false ), - 'bracketed ipv6 with port is rejected' => array( '[2001:db8::1]:8080', false ), - 'bracketed ipv6 loopback is rejected' => array( '[::1]', false ), - 'empty brackets are rejected' => array( '[]', false ), + 'bracketed ipv6 is rejected' => array( '[2001:db8::1]', false ), + 'bracketed ipv6 with port is rejected' => array( '[2001:db8::1]:8080', false ), + 'bracketed ipv6 loopback is rejected' => array( '[::1]', false ), + 'empty brackets are rejected' => array( '[]', false ), 'unbracketed ipv6-shaped string with a port-like trailing group is rejected' => array( '1:2:3:4:5:6:7:8:9', false ), - 'empty domain' => array( '', false ), - 'domain with a space' => array( 'example org', false ), - 'domain with a scheme' => array( 'http://example.org', false ), - 'leading hyphen label' => array( '-example.org', false ), - 'empty label' => array( 'example..org', false ), - 'port out of range' => array( 'example.org:99999', false ), - 'non numeric port' => array( 'example.org:abc', false ), + 'empty domain' => array( '', false ), + 'domain with a space' => array( 'example org', false ), + 'domain with a scheme' => array( 'http://example.org', false ), + 'leading hyphen label' => array( '-example.org', false ), + 'empty label' => array( 'example..org', false ), + 'port out of range' => array( 'example.org:99999', false ), + 'non numeric port' => array( 'example.org:abc', false ), ); } @@ -1276,20 +1492,20 @@ public function test_update_item_path_validation( $path, $is_valid ) { */ public function data_path_validation() { return array( - 'root path' => array( '/', true ), - 'single segment' => array( '/tempor/', true ), - 'nested segments' => array( '/parent/child/', true ), - 'segment with apostrophe' => array( "/o'brien/", true ), - 'segment with punctuation' => array( '/with-hyphen_and.dot~tilde/', true ), - 'percent encoded segment' => array( '/percent%20encoded/', true ), - 'empty path' => array( '', false ), - 'no leading slash' => array( 'no-leading-slash/', false ), - 'no trailing slash' => array( '/no-trailing-slash', false ), - 'double slash' => array( '//double-slash//', false ), - 'path with a space' => array( '/with space/', false ), - 'path with a query string' => array( '/with?query/', false ), - 'path with a fragment' => array( '/with#fragment/', false ), - 'path with a double quote' => array( '/with"quote/', false ), + 'root path' => array( '/', true ), + 'single segment' => array( '/tempor/', true ), + 'nested segments' => array( '/parent/child/', true ), + 'segment with apostrophe' => array( "/o'brien/", true ), + 'segment with punctuation' => array( '/with-hyphen_and.dot~tilde/', true ), + 'percent encoded segment' => array( '/percent%20encoded/', true ), + 'empty path' => array( '', false ), + 'no leading slash' => array( 'no-leading-slash/', false ), + 'no trailing slash' => array( '/no-trailing-slash', false ), + 'double slash' => array( '//double-slash//', false ), + 'path with a space' => array( '/with space/', false ), + 'path with a query string' => array( '/with?query/', false ), + 'path with a fragment' => array( '/with#fragment/', false ), + 'path with a double quote' => array( '/with"quote/', false ), ); } From 0edb70ee8c593dfb41e7e79dab28ec18a3b5a6e1 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Mon, 28 Sep 2026 23:28:18 +0100 Subject: [PATCH 20/38] Multisite: Fix follow-up permission-check inconsistencies in the sites REST controller. A wrapido-based manual QA pass against 4aa194739e (which fixed the network-wide-delete and view-context info-disclosure bugs) surfaced four smaller inconsistencies introduced or left by that refactor: - `get_item_permissions_check()`'s membership bypass only matched the literal `view` context, so a site member got 200 on the default view context but 403 on `context=embed` - stricter than `view` even though embed is normally a strict subset of it. Now also allows `embed`. - `get_items_permissions_check()`'s fallback error said "not allowed to edit sites" for what is a read (GET) endpoint - copy-pasted from the manage_sites-gated edit check it now reuses. Reworded to match the single-item route's "not allowed to view sites". - `is_own_user_filter()` returned `true` for `user=me` unconditionally, so a logged-out `GET /sites?user=me` returned 200 (empty) instead of 401 - an unintentional anonymous-access bypass. Now requires being logged in. - `update_item()` unconditionally forced the response context to `edit`, while `create_item()` (changed by the same commit) now respects an explicit `context` request param, defaulting to `view`. Made `update_item()` consistent with `create_item()`. Updates test_get_items_me_filter_is_readable_when_logged_out to assert the corrected (403) behavior, and adds a regression test for the new embed-context allowance. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UGF7E1FnyB4fpDGdwoDMyc --- .../class-wp-rest-sites-controller.php | 9 +++--- .../tests/rest-api/wpRestSitesController.php | 31 ++++++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) 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 9f330f8457030..049409ef42797 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 @@ -132,7 +132,7 @@ public function get_items_permissions_check( $request ) { return true; } - return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to view sites.' ), array( 'status' => rest_authorization_required_code() ) ); } /** @@ -151,7 +151,7 @@ protected function is_own_user_filter( $request ) { } if ( 'me' === $user ) { - return true; + return is_user_logged_in(); } return get_current_user_id() === (int) $user; @@ -403,7 +403,7 @@ public function get_item_permissions_check( $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - if ( 'view' === $context && is_user_member_of_blog( get_current_user_id(), (int) $site->blog_id ) ) { + if ( in_array( $context, array( 'view', 'embed' ), true ) && is_user_member_of_blog( get_current_user_id(), (int) $site->blog_id ) ) { return true; } @@ -641,7 +641,8 @@ public function update_item( $request ) { return $fields_update; } - $request->set_param( 'context', 'edit' ); + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $request->set_param( 'context', $context ); $response = $this->prepare_item_for_response( $site, $request ); diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index f4c67c056802f..7f2f079b9480e 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -156,20 +156,20 @@ public function test_get_items_forbidden_when_logged_out() { } /** - * The collection is intentionally readable while logged out, as long - * as the request is scoped to "my sites" via the `user` filter. + * The `user=me` own-sites filter requires being logged in, same as any + * other collection request - it is not an anonymous-access bypass. * * @ticket 40365 * @covers ::get_items_permissions_check * @group ms-required */ - public function test_get_items_me_filter_is_readable_when_logged_out() { + public function test_get_items_me_filter_forbidden_when_logged_out() { $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); $request->set_param( 'user', 'me' ); $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 200, $response->get_status() ); + $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); } /** @@ -248,6 +248,29 @@ public function test_get_item_view_context_allowed_for_site_member() { $this->assertEquals( 200, $response->get_status() ); } + /** + * A member of the site (even without `manage_sites`) can also view it + * in the `embed` context, same as the default `view` context. + * + * @ticket 40365 + * @covers ::get_item_permissions_check + * @group ms-required + */ + public function test_get_item_embed_context_allowed_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' ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'context', 'embed' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + /** * A logged-in user who is neither a super admin nor a member of the * site is forbidden from viewing it, even in the default view context. From 37343020fcaba1d6f5ea6ce91a0695c60812a871 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Tue, 29 Sep 2026 08:09:34 +0100 Subject: [PATCH 21/38] Multisite: Close a related anonymous-access gap in the sites `user` filter. Code review of the previous commit found the same class of bug it had just fixed for `user=me` also affects any non-numeric `user` value: `is_own_user_filter()` cast `$user` to `(int)` and compared it against `get_current_user_id()` with no logged-in guard, so a logged-out request (current user id 0) with e.g. `?user=xyz` satisfied `0 === (int) 'xyz'` and was treated as an authorized own-sites request. Require `is_user_logged_in()` for the general (non-"me") comparison too, matching the guard already added for the literal `me` case. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UGF7E1FnyB4fpDGdwoDMyc --- .../class-wp-rest-sites-controller.php | 2 +- .../tests/rest-api/wpRestSitesController.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 049409ef42797..cf09539e79853 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 @@ -154,7 +154,7 @@ protected function is_own_user_filter( $request ) { return is_user_logged_in(); } - return get_current_user_id() === (int) $user; + return is_user_logged_in() && get_current_user_id() === (int) $user; } /** diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 7f2f079b9480e..148c20919e2d4 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -172,6 +172,24 @@ public function test_get_items_me_filter_forbidden_when_logged_out() { $this->assertErrorResponse( 'rest_forbidden_context', $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. + * + * @ticket 40365 + * @covers ::get_items_permissions_check + * @group ms-required + */ + public function test_get_items_non_numeric_user_filter_forbidden_when_logged_out() { + $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 ); + } + /** * @ticket 40365 * @covers ::get_item From 13b9603cf26811ac959a9e81c129106a9893e397 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Tue, 29 Sep 2026 21:38:53 +0100 Subject: [PATCH 22/38] Define allow_batch support for site endpoints --- .../endpoints/class-wp-rest-sites-controller.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 7c604035e34e4..22bbc25fdbe3f 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 @@ -16,6 +16,14 @@ */ class WP_REST_Sites_Controller extends WP_REST_Controller { + /** + * Whether the controller supports batching. + * + * @since 7.2.0 + * @var false + */ + protected $allow_batch = false; + /** * Instance of a site meta fields object. * @@ -60,7 +68,8 @@ public function register_routes() { 'permission_callback' => array( $this, 'create_item_permissions_check' ), 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), ), - 'schema' => array( $this, 'get_public_item_schema' ), + 'allow_batch' => $this->allow_batch, + 'schema' => array( $this, 'get_public_item_schema' ), ) ); @@ -72,7 +81,7 @@ public function register_routes() { $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)', array( - 'args' => array( + 'args' => array( 'id' => array( 'description' => __( 'Unique identifier for the object.' ), 'type' => 'integer', @@ -104,7 +113,8 @@ public function register_routes() { ), ), ), - 'schema' => array( $this, 'get_public_item_schema' ), + 'allow_batch' => $this->allow_batch, + 'schema' => array( $this, 'get_public_item_schema' ), ) ); } From 6f0330c5cf7cdd02fb0dde0c9c6dcc6ef4917a5b Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Wed, 30 Sep 2026 13:40:57 +0100 Subject: [PATCH 23/38] Add per-network super-admin scoping to REST sites controller (WIP) Co-Authored-By: Claude Sonnet 5 --- src/wp-includes/capabilities.php | 30 ++- .../class-wp-rest-sites-controller.php | 114 ++++++++- .../tests/rest-api/wpRestSitesController.php | 218 ++++++++++++++++++ tests/phpunit/tests/user/capabilities.php | 106 +++++++++ 4 files changed, 462 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 6f8b57d50b348..721ac4b434967 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1155,26 +1155,46 @@ function remove_role( $role ) { * Retrieves a list of super admins. * * @since 3.0.0 + * @since 7.2.0 Added the `$network_id` parameter. * * @global array $super_admins * + * @param int|null $network_id Optional. ID of the network. Default is the current network. * @return string[] List of super admin logins. */ -function get_super_admins() { +function get_super_admins( $network_id = null ) { global $super_admins; - return $super_admins ?? get_site_option( 'site_admins', array( 'admin' ) ); + // Falsy values (including 0) mean "unspecified", matching get_network_option()'s own fallback. + $is_current_network = ! $network_id || (int) $network_id === get_current_network_id(); + + if ( $is_current_network && isset( $super_admins ) ) { + return $super_admins; + } + + /* + * The 'admin' fallback exists for the current network only, where it reflects the + * install's original default super admin. A network whose `site_admins` option was + * never explicitly set has no default super admins - assuming 'admin' there would let + * any user with that login be treated as a super admin of a network they were never + * granted access to. + */ + $default = $is_current_network ? array( 'admin' ) : array(); + + return get_network_option( $network_id, 'site_admins', $default ); } /** * Determines whether user is a site admin. * * @since 3.0.0 + * @since 7.2.0 Added the `$network_id` parameter. * - * @param int|false $user_id Optional. The ID of a user. Defaults to false, to check the current user. + * @param int|false $user_id Optional. The ID of a user. Defaults to false, to check the current user. + * @param int|null $network_id Optional. ID of the network. Default is the current network. * @return bool Whether the user is a site admin. */ -function is_super_admin( $user_id = false ) { +function is_super_admin( $user_id = false, $network_id = null ) { if ( ! $user_id ) { $user = wp_get_current_user(); } else { @@ -1186,7 +1206,7 @@ function is_super_admin( $user_id = false ) { } if ( is_multisite() ) { - $super_admins = get_super_admins(); + $super_admins = get_super_admins( $network_id ); if ( is_array( $super_admins ) && in_array( $user->user_login, $super_admins, true ) ) { return true; } 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 7c604035e34e4..cf80b69c7d09e 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 @@ -136,6 +136,30 @@ public function get_items_permissions_check( $request ) { return true; } + if ( isset( $request['network'] ) ) { + $network_check = $this->check_network_ids_exist( (array) $request['network'] ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + + $network_access = $this->check_network_access( get_current_user_id(), (array) $request['network'] ); + if ( is_wp_error( $network_access ) ) { + return $network_access; + } + } + + if ( isset( $request['network_exclude'] ) ) { + $network_check = $this->check_network_ids_exist( (array) $request['network_exclude'] ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + + $network_access = $this->check_network_access( get_current_user_id(), (array) $request['network_exclude'] ); + if ( is_wp_error( $network_access ) ) { + return $network_access; + } + } + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit sites.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -408,6 +432,18 @@ public function get_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } + if ( $site->network_id > 0 ) { + $network_check = $this->check_network_ids_exist( (array) $site->network_id ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + + $network_access = $this->check_network_access( get_current_user_id(), (array) $site->network_id ); + if ( is_wp_error( $network_access ) ) { + return $network_access; + } + } + if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'manage_sites' ) ) { return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to view sites.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -415,6 +451,55 @@ public function get_item_permissions_check( $request ) { return true; } + /** + * Validates that all network IDs in a request parameter exist. + * + * @since 7.2.0 + * + * @param int[] $network_ids Array of network IDs. + * @return true|WP_Error True if all network IDs are valid, WP_Error otherwise. + */ + protected function check_network_ids_exist( array $network_ids ) { + foreach ( $network_ids as $network_id ) { + if ( ! get_network( $network_id ) ) { + return new WP_Error( 'rest_network_id_invalid', __( 'Invalid network ID.' ), array( 'status' => 400 ) ); + } + } + + return true; + } + + /** + * Checks if a user can access sites on the given networks. + * + * A user can always access the current network. Access to any other + * network requires the user to be a super admin of that network. + * + * @since 7.2.0 + * + * @param int $user_id User ID. + * @param int[] $network_ids Array of network IDs. + * @return true|WP_Error True if the user can access all given networks, WP_Error otherwise. + */ + protected function check_network_access( $user_id, array $network_ids ) { + $current_network_id = get_current_network_id(); + if ( count( $network_ids ) === 1 && $current_network_id === $network_ids[0] ) { + return true; + } + + foreach ( $network_ids as $network_id ) { + if ( (int) $network_id === $current_network_id ) { + continue; + } + + if ( ! is_super_admin( $user_id, $network_id ) ) { + return new WP_Error( 'rest_cannot_view_network', __( 'Sorry, you are not allowed to access sites on this network.' ), array( 'status' => rest_authorization_required_code() ) ); + } + } + + return true; + } + /** * Retrieves a site. * @@ -452,6 +537,19 @@ public function create_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } + $network_id = isset( $request['network'] ) ? (int) $request['network'] : get_current_network_id(); + if ( $network_id > 0 ) { + $network_check = $this->check_network_ids_exist( (array) $network_id ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + + $network_access = $this->check_network_access( get_current_user_id(), (array) $network_id ); + if ( is_wp_error( $network_access ) ) { + return $network_access; + } + } + return current_user_can( 'create_sites' ); } @@ -569,7 +667,8 @@ public function update_item_permissions_check( $request ) { return $site; } - if ( 0 === get_current_user_id() ) { + $current_user_id = get_current_user_id(); + if ( 0 === $current_user_id ) { return false; } @@ -577,6 +676,19 @@ public function update_item_permissions_check( $request ) { return new WP_Error( 'rest_multisite_not_installed', __( 'Multisite is not installed' ), array( 'status' => 400 ) ); } + $network_id = isset( $request['network'] ) ? (int) $request['network'] : $site->network_id; + if ( $network_id > 0 ) { + $network_check = $this->check_network_ids_exist( (array) $network_id ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + + $network_access = $this->check_network_access( $current_user_id, (array) $network_id ); + if ( is_wp_error( $network_access ) ) { + return $network_access; + } + } + if ( ! $this->check_edit_permission( $site ) ) { return new WP_Error( 'rest_cannot_edit', __( 'Sorry, you are not allowed to edit this site.' ), array( 'status' => rest_authorization_required_code() ) ); } diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 621e534a9f86f..56803105558c6 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -1455,4 +1455,222 @@ public function test_get_items_filter_with_includes_user() { $this->assertCount( 1, $sites ); $this->assertEquals( array( $blog_ids[0] ), wp_list_pluck( $sites, 'id' ) ); } + + /** + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::check_network_ids_exist + * @group ms-required + */ + public function test_get_items_permissions_check_invalid_network_id() { + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'network', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::check_network_ids_exist + * @group ms-required + */ + public function test_get_items_permissions_check_invalid_network_exclude_id() { + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'network_exclude', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_get_items_permissions_check_denies_access_to_another_network() { + wp_set_current_user( self::factory()->user->create() ); + $network_id = self::factory()->network->create(); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'network', $network_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + } + + /** + * @ticket 40365 + * @covers ::get_items_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_get_items_permissions_check_allows_filtering_by_the_current_network() { + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'network', get_current_network_id() ); + + $response = rest_get_server()->dispatch( $request ); + + // The user still lacks manage_sites, but the network filter itself is not the reason. + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + /** + * @ticket 40365 + * @covers ::create_item_permissions_check + * @covers ::check_network_ids_exist + * @group ms-required + */ + public function test_create_item_permissions_check_invalid_network_id() { + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/rejected/' ); + $request->set_param( 'network', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::create_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_create_item_permissions_check_denies_access_to_another_network() { + wp_set_current_user( self::factory()->user->create() ); + $network_id = self::factory()->network->create(); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', WP_TESTS_DOMAIN ); + $request->set_param( 'path', '/rejected/' ); + $request->set_param( 'network', $network_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + } + + /** + * @ticket 40365 + * @covers ::create_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_create_item_permissions_check_allows_a_super_admin_of_the_target_network() { + $user = self::factory()->user->create_and_get(); + $network_id = self::factory()->network->create(); + update_network_option( $network_id, 'site_admins', array( $user->user_login ) ); + wp_set_current_user( $user->ID ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'network', $network_id ); + + $result = $this->endpoint->create_item_permissions_check( $request ); + + // The network check itself passes; any remaining denial is due to the unrelated create_sites capability. + $this->assertNotWPError( $result ); + } + + /** + * @ticket 40365 + * @covers ::update_item_permissions_check + * @covers ::check_network_ids_exist + * @group ms-required + */ + public function test_update_item_permissions_check_invalid_network_id() { + $blog_id = self::factory()->blog->create(); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + $request->set_param( 'network', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::update_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_update_item_permissions_check_denies_moving_a_site_to_another_network() { + $blog_id = self::factory()->blog->create(); + $network_id = self::factory()->network->create(); + delete_network_option( $network_id, 'site_admins' ); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + $request->set_param( 'network', $network_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + } + + /** + * @ticket 40365 + * @covers ::update_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_update_item_permissions_check_allows_the_sites_own_network_by_default() { + $blog_id = self::factory()->blog->create(); + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * @ticket 40365 + * @covers ::get_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_get_item_permissions_check_denies_a_site_on_another_network() { + wp_set_current_user( self::$superadmin_id ); + $network_id = self::factory()->network->create(); + delete_network_option( $network_id, 'site_admins' ); + $blog_id = self::factory()->blog->create( array( 'site_id' => $network_id ) ); + + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + } + + /** + * @ticket 40365 + * @covers ::get_item_permissions_check + * @covers ::check_network_access + * @group ms-required + */ + public function test_get_item_permissions_check_allows_a_site_on_the_current_network() { + $blog_id = self::factory()->blog->create(); + wp_set_current_user( self::factory()->user->create() ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + } } diff --git a/tests/phpunit/tests/user/capabilities.php b/tests/phpunit/tests/user/capabilities.php index 30f6a2144a02b..4185c518a2553 100644 --- a/tests/phpunit/tests/user/capabilities.php +++ b/tests/phpunit/tests/user/capabilities.php @@ -2594,4 +2594,110 @@ public function test_role_capabilities_updated_correctly_via_update_option() { $this->assertSameSetsWithIndex( $emcee_caps, $sally_caps, 'Emcee and Sally roles should have the same capabilities after update.' ); $this->assertLessThan( $emcee_queries, $sally_queries, 'Updating roles via update_option should be more efficient than WP_Roles using the database.' ); } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::get_super_admins + */ + public function test_get_super_admins_with_explicit_current_network_id_matches_default() { + $this->assertSameSets( get_super_admins(), get_super_admins( get_current_network_id() ) ); + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::get_super_admins + */ + public function test_get_super_admins_for_another_network_returns_that_networks_site_admins() { + $network_id = self::factory()->network->create(); + update_network_option( $network_id, 'site_admins', array( 'someone-else' ) ); + + $this->assertSameSets( array( 'someone-else' ), get_super_admins( $network_id ) ); + $this->assertNotSame( get_super_admins(), get_super_admins( $network_id ) ); + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::get_super_admins + */ + public function test_get_super_admins_for_another_network_without_a_site_admins_option_is_empty() { + $network_id = self::factory()->network->create(); + delete_network_option( $network_id, 'site_admins' ); + + // Unlike the current network, a network with no `site_admins` option has no implicit 'admin' fallback. + $this->assertSame( array(), get_super_admins( $network_id ) ); + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::get_super_admins + */ + public function test_get_super_admins_global_override_does_not_apply_to_other_networks() { + $network_id = self::factory()->network->create(); + $had_global_override = array_key_exists( 'super_admins', $GLOBALS ); + $old_super_admins = $had_global_override ? $GLOBALS['super_admins'] : null; + delete_network_option( $network_id, 'site_admins' ); + update_network_option( $network_id, 'site_admins', array( 'network-admin' ) ); + + $GLOBALS['super_admins'] = array( 'global-override' ); + + $this->assertSameSets( array( 'global-override' ), get_super_admins() ); + $this->assertSameSets( array( 'network-admin' ), get_super_admins( $network_id ) ); + + if ( $had_global_override ) { + $GLOBALS['super_admins'] = $old_super_admins; + } else { + unset( $GLOBALS['super_admins'] ); + } + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::is_super_admin + */ + public function test_is_super_admin_defaults_to_the_current_network() { + $user = self::$super_admin; + + $this->assertTrue( is_super_admin( $user->ID ) ); + $this->assertTrue( is_super_admin( $user->ID, get_current_network_id() ) ); + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::is_super_admin + */ + public function test_is_super_admin_for_a_network_the_user_is_not_a_super_admin_of() { + $user = self::$super_admin; + $network_id = self::factory()->network->create(); + delete_network_option( $network_id, 'site_admins' ); + + $this->assertTrue( is_super_admin( $user->ID ), 'User is expected to be a super admin of the current network.' ); + $this->assertFalse( is_super_admin( $user->ID, $network_id ), 'User is not expected to be a super admin of an unrelated network.' ); + } + + /** + * @ticket 40365 + * @group ms-required + * + * @covers ::is_super_admin + */ + public function test_is_super_admin_for_a_network_the_user_is_a_super_admin_of() { + $user = self::factory()->user->create_and_get(); + $network_id = self::factory()->network->create(); + update_network_option( $network_id, 'site_admins', array( $user->user_login ) ); + + $this->assertFalse( is_super_admin( $user->ID ), 'User is not expected to be a super admin of the current network.' ); + $this->assertTrue( is_super_admin( $user->ID, $network_id ), 'User is expected to be a super admin of the network granting them access.' ); + } } From bb56c858fefe2f24a31fc5b87368fc833deb3ebe Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Wed, 30 Sep 2026 22:38:40 +0200 Subject: [PATCH 24/38] Coding Standards: Use Yoda condition in get_super_admins(). --- src/wp-includes/capabilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 721ac4b434967..8a9eb293ba268 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1166,7 +1166,7 @@ function get_super_admins( $network_id = null ) { global $super_admins; // Falsy values (including 0) mean "unspecified", matching get_network_option()'s own fallback. - $is_current_network = ! $network_id || (int) $network_id === get_current_network_id(); + $is_current_network = ! $network_id || get_current_network_id() === (int) $network_id; if ( $is_current_network && isset( $super_admins ) ) { return $super_admins; From b94c719092eb4231328fb85a2e1f5b437a89f902 Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Wed, 30 Sep 2026 22:41:47 +0200 Subject: [PATCH 25/38] Coding Standards: Equals sign not aligned with surrounding assignments. --- tests/phpunit/tests/user/capabilities.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/user/capabilities.php b/tests/phpunit/tests/user/capabilities.php index a00c60f796403..7d7bc597dae25 100644 --- a/tests/phpunit/tests/user/capabilities.php +++ b/tests/phpunit/tests/user/capabilities.php @@ -2636,9 +2636,9 @@ public function test_get_super_admins_for_another_network_without_a_site_admins_ * @covers ::get_super_admins */ public function test_get_super_admins_global_override_does_not_apply_to_other_networks() { - $network_id = self::factory()->network->create(); - $had_global_override = array_key_exists( 'super_admins', $GLOBALS ); - $old_super_admins = $had_global_override ? $GLOBALS['super_admins'] : null; + $network_id = self::factory()->network->create(); + $had_global_override = array_key_exists( 'super_admins', $GLOBALS ); + $old_super_admins = $had_global_override ? $GLOBALS['super_admins'] : null; delete_network_option( $network_id, 'site_admins' ); update_network_option( $network_id, 'site_admins', array( 'network-admin' ) ); From 32df8513da0bd2a3c9352bc9bc27fa439ab1054c Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Wed, 30 Sep 2026 22:28:55 +0100 Subject: [PATCH 26/38] Update tests/qunit/fixtures/wp-api-generated.js --- tests/qunit/fixtures/wp-api-generated.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index f54dfc053279d..f9290b13df039 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11089,7 +11089,6 @@ mockedApiResponse.Schema = { ], "args": { "network": { - "default": 1, "description": "The site's network ID. Default is the current network ID.", "type": "integer", "required": false From e4597ba4fefa587f944918a24d69bf3706e6e191 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Wed, 30 Sep 2026 23:21:36 +0100 Subject: [PATCH 27/38] REST API: Merge network-ID permission checks into a single method. Combines check_network_ids_exist() and check_network_access() into one check_network_ids() method, used consistently across get_items, get_item, create_item, update_item, and delete_item_permissions_check(). Also adds test coverage for delete_item_permissions_check() network validation, which previously had none. Co-Authored-By: Claude Sonnet 5 --- .../class-wp-rest-sites-controller.php | 92 ++++++------------- .../tests/rest-api/wpRestSitesController.php | 87 +++++++++++++++--- tests/qunit/fixtures/wp-api-generated.js | 9 +- 3 files changed, 106 insertions(+), 82 deletions(-) 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 feb12673e951e..b3c547d84da25 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 @@ -133,6 +133,16 @@ public function get_items_permissions_check( $request ) { return $multisite_support; } + foreach ( array( 'network', 'network_exclude' ) as $network_key ) { + if ( ! isset( $request[ $network_key ] ) ) { + continue; + } + $network_check = $this->check_network_ids( (array) $request[ $network_key ] ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + } + if ( $this->check_edit_permission() ) { return true; } @@ -142,30 +152,6 @@ public function get_items_permissions_check( $request ) { return true; } - if ( isset( $request['network'] ) ) { - $network_check = $this->check_network_ids_exist( (array) $request['network'] ); - if ( is_wp_error( $network_check ) ) { - return $network_check; - } - - $network_access = $this->check_network_access( get_current_user_id(), (array) $request['network'] ); - if ( is_wp_error( $network_access ) ) { - return $network_access; - } - } - - if ( isset( $request['network_exclude'] ) ) { - $network_check = $this->check_network_ids_exist( (array) $request['network_exclude'] ); - if ( is_wp_error( $network_check ) ) { - return $network_check; - } - - $network_access = $this->check_network_access( get_current_user_id(), (array) $request['network_exclude'] ); - if ( is_wp_error( $network_access ) ) { - return $network_access; - } - } - return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to view sites.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -436,15 +422,10 @@ public function get_item_permissions_check( $request ) { } if ( $site->network_id > 0 ) { - $network_check = $this->check_network_ids_exist( (array) $site->network_id ); + $network_check = $this->check_network_ids( (array) $site->network_id ); if ( is_wp_error( $network_check ) ) { return $network_check; } - - $network_access = $this->check_network_access( get_current_user_id(), (array) $site->network_id ); - if ( is_wp_error( $network_access ) ) { - return $network_access; - } } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; @@ -461,37 +442,27 @@ public function get_item_permissions_check( $request ) { } /** - * Validates that all network IDs in a request parameter exist. + * Validates that the given network IDs exist and are accessible to the current user. + * + * A user can always access the current network. Access to any other + * network requires the user to be a super admin of that network. * * @since 7.2.0 * * @param int[] $network_ids Array of network IDs. - * @return true|WP_Error True if all network IDs are valid, WP_Error otherwise. + * @return true|WP_Error True if all network IDs exist and are accessible, WP_Error otherwise. */ - protected function check_network_ids_exist( array $network_ids ) { + protected function check_network_ids( array $network_ids ) { + $current_network_id = get_current_network_id(); + $user_id = get_current_user_id(); + + _prime_network_caches( $network_ids ); foreach ( $network_ids as $network_id ) { if ( ! get_network( $network_id ) ) { return new WP_Error( 'rest_network_id_invalid', __( 'Invalid network ID.' ), array( 'status' => 400 ) ); } } - return true; - } - - /** - * Checks if a user can access sites on the given networks. - * - * A user can always access the current network. Access to any other - * network requires the user to be a super admin of that network. - * - * @since 7.2.0 - * - * @param int $user_id User ID. - * @param int[] $network_ids Array of network IDs. - * @return true|WP_Error True if the user can access all given networks, WP_Error otherwise. - */ - protected function check_network_access( $user_id, array $network_ids ) { - $current_network_id = get_current_network_id(); if ( count( $network_ids ) === 1 && $current_network_id === $network_ids[0] ) { return true; } @@ -545,15 +516,10 @@ public function create_item_permissions_check( $request ) { $network_id = isset( $request['network'] ) ? (int) $request['network'] : get_current_network_id(); if ( $network_id > 0 ) { - $network_check = $this->check_network_ids_exist( (array) $network_id ); + $network_check = $this->check_network_ids( (array) $network_id ); if ( is_wp_error( $network_check ) ) { return $network_check; } - - $network_access = $this->check_network_access( get_current_user_id(), (array) $network_id ); - if ( is_wp_error( $network_access ) ) { - return $network_access; - } } if ( ! current_user_can( 'create_sites' ) ) { @@ -683,15 +649,10 @@ public function update_item_permissions_check( $request ) { $network_id = isset( $request['network'] ) ? (int) $request['network'] : $site->network_id; if ( $network_id > 0 ) { - $network_check = $this->check_network_ids_exist( (array) $network_id ); + $network_check = $this->check_network_ids( (array) $network_id ); if ( is_wp_error( $network_check ) ) { return $network_check; } - - $network_access = $this->check_network_access( get_current_user_id(), (array) $network_id ); - if ( is_wp_error( $network_access ) ) { - return $network_access; - } } if ( ! $this->check_edit_permission() ) { @@ -789,6 +750,13 @@ public function delete_item_permissions_check( $request ) { return $site; } + if ( $site->network_id > 0 ) { + $network_check = $this->check_network_ids( (array) $site->network_id ); + if ( is_wp_error( $network_check ) ) { + return $network_check; + } + } + if ( ! $this->check_delete_permission( $site ) ) { return new WP_Error( 'rest_cannot_delete', __( 'Sorry, you are not allowed to delete this site.' ), array( 'status' => rest_authorization_required_code() ) ); } diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 3a0e02b286734..753f1a6f79180 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -1999,7 +1999,7 @@ public function test_get_items_filter_with_includes_user() { /** * @ticket 40365 * @covers ::get_items_permissions_check - * @covers ::check_network_ids_exist + * @covers ::check_network_ids * @group ms-required */ public function test_get_items_permissions_check_invalid_network_id() { @@ -2015,7 +2015,7 @@ public function test_get_items_permissions_check_invalid_network_id() { /** * @ticket 40365 * @covers ::get_items_permissions_check - * @covers ::check_network_ids_exist + * @covers ::check_network_ids * @group ms-required */ public function test_get_items_permissions_check_invalid_network_exclude_id() { @@ -2031,7 +2031,7 @@ public function test_get_items_permissions_check_invalid_network_exclude_id() { /** * @ticket 40365 * @covers ::get_items_permissions_check - * @covers ::check_network_access + * @covers ::check_network_ids * @group ms-required */ public function test_get_items_permissions_check_denies_access_to_another_network() { @@ -2048,7 +2048,7 @@ public function test_get_items_permissions_check_denies_access_to_another_networ /** * @ticket 40365 * @covers ::get_items_permissions_check - * @covers ::check_network_access + * @covers ::check_network_ids * @group ms-required */ public function test_get_items_permissions_check_allows_filtering_by_the_current_network() { @@ -2066,7 +2066,7 @@ public function test_get_items_permissions_check_allows_filtering_by_the_current /** * @ticket 40365 * @covers ::create_item_permissions_check - * @covers ::check_network_ids_exist + * @covers ::check_network_ids * @group ms-required */ public function test_create_item_permissions_check_invalid_network_id() { @@ -2084,7 +2084,7 @@ public function test_create_item_permissions_check_invalid_network_id() { /** * @ticket 40365 * @covers ::create_item_permissions_check - * @covers ::check_network_access + * @covers ::check_network_ids * @group ms-required */ public function test_create_item_permissions_check_denies_access_to_another_network() { @@ -2103,7 +2103,7 @@ public function test_create_item_permissions_check_denies_access_to_another_netw /** * @ticket 40365 * @covers ::create_item_permissions_check - * @covers ::check_network_access + * @covers ::check_network_ids * @group ms-required */ public function test_create_item_permissions_check_allows_a_super_admin_of_the_target_network() { @@ -2123,7 +2123,7 @@ public function test_create_item_permissions_check_allows_a_super_admin_of_the_t /** * @ticket 40365 * @covers ::update_item_permissions_check - * @covers ::check_network_ids_exist + * @covers ::check_network_ids * @group ms-required */ public function test_update_item_permissions_check_invalid_network_id() { @@ -2141,7 +2141,7 @@ public function test_update_item_permissions_check_invalid_network_id() { /** * @ticket 40365 * @covers ::update_item_permissions_check - * @covers ::check_network_access + * @covers ::check_network_ids * @group ms-required */ public function test_update_item_permissions_check_denies_moving_a_site_to_another_network() { @@ -2161,7 +2161,7 @@ public function test_update_item_permissions_check_denies_moving_a_site_to_anoth /** * @ticket 40365 * @covers ::update_item_permissions_check - * @covers ::check_network_access + * @covers ::check_network_ids * @group ms-required */ public function test_update_item_permissions_check_allows_the_sites_own_network_by_default() { @@ -2175,10 +2175,73 @@ public function test_update_item_permissions_check_allows_the_sites_own_network_ $this->assertEquals( 200, $response->get_status() ); } + /** + * @ticket 40365 + * @covers ::delete_item_permissions_check + * @covers ::check_network_ids + * @group ms-required + */ + public function test_delete_item_permissions_check_invalid_network_id() { + $blog_id = self::factory()->blog->create(); + + // Orphan the site from its network to simulate an invalid stored network ID. + wp_update_site( $blog_id, array( 'network_id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); + + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + } + + /** + * @ticket 40365 + * @covers ::delete_item_permissions_check + * @covers ::check_network_ids + * @group ms-required + */ + public function test_delete_item_permissions_check_denies_access_to_another_network() { + $network_id = self::factory()->network->create(); + delete_network_option( $network_id, 'site_admins' ); + $blog_id = self::factory()->blog->create( array( 'site_id' => $network_id ) ); + + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + } + + /** + * @ticket 40365 + * @covers ::delete_item_permissions_check + * @covers ::check_network_ids + * @group ms-required + */ + public function test_delete_item_permissions_check_allows_a_super_admin_of_the_sites_network() { + $network_id = self::factory()->network->create(); + $user = self::factory()->user->create_and_get(); + update_network_option( $network_id, 'site_admins', array( $user->user_login ) ); + // delete_sites is checked against the current network, so grant it there too. + grant_super_admin( $user->ID ); + $blog_id = self::factory()->blog->create( array( 'site_id' => $network_id ) ); + + wp_set_current_user( $user->ID ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); + + $this->assertTrue( $this->endpoint->delete_item_permissions_check( $request ) ); + } + /** * @ticket 40365 * @covers ::get_item_permissions_check - * @covers ::check_network_access + * @covers ::check_network_ids * @group ms-required */ public function test_get_item_permissions_check_denies_a_site_on_another_network() { @@ -2199,7 +2262,7 @@ public function test_get_item_permissions_check_denies_a_site_on_another_network /** * @ticket 40365 * @covers ::get_item_permissions_check - * @covers ::check_network_access + * @covers ::check_network_ids * @group ms-required */ public function test_get_item_permissions_check_allows_a_site_on_the_current_network() { diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index f54dfc053279d..85cd6b8785df3 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -22,13 +22,7 @@ mockedApiResponse.Schema = { "wp-block-editor/v1", "wp-abilities/v1" ], - "authentication": { - "application-passwords": { - "endpoints": { - "authorization": "http://example.org/wp-admin/authorize-application.php" - } - } - }, + "authentication": [], "routes": { "/": { "namespace": "", @@ -11209,7 +11203,6 @@ mockedApiResponse.Schema = { "required": false }, "network": { - "default": 1, "description": "The site's network ID. Default is the current network ID.", "type": "integer", "required": false From 959bf3d24d75c86bebce7d7c323e6cac0a549845 Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Thu, 1 Oct 2026 19:58:55 +0100 Subject: [PATCH 28/38] Fix tests --- .../rest-api/endpoints/class-wp-rest-sites-controller.php | 1 - 1 file changed, 1 deletion(-) 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 b3c547d84da25..49a28912cb3a0 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 @@ -1318,7 +1318,6 @@ public function get_item_schema() { 'description' => __( 'The site\'s network ID. Default is the current network ID.' ), 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), - 'default' => get_current_network_id(), ), 'domain' => array( 'description' => __( 'Site domain.' ), From 6cb9041378008e4bbf38b3c0e12114a316ab17a5 Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Thu, 1 Oct 2026 20:14:01 +0100 Subject: [PATCH 29/38] Apply suggestion from @spacedmonkey --- tests/qunit/fixtures/wp-api-generated.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 5e6ebf0a5ae9a..99580f63b4e27 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -22,7 +22,13 @@ mockedApiResponse.Schema = { "wp-block-editor/v1", "wp-abilities/v1" ], - "authentication": [], + "authentication": { + "application-passwords": { + "endpoints": { + "authorization": "http://example.org/wp-admin/authorize-application.php" + } + } + }, "routes": { "/": { "namespace": "", From 7ecdb293b4253b615df6eaf487d0744b319c8777 Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Fri, 2 Oct 2026 10:34:02 +0100 Subject: [PATCH 30/38] Enhance REST API: Default site retrieval to current network if no network filter is provided --- .../class-wp-rest-sites-controller.php | 5 +++ .../tests/rest-api/wpRestSitesController.php | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+) 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 49a28912cb3a0..6e2b1b94c3b95 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 @@ -230,6 +230,11 @@ public function get_items( $request ) { } } + // Without an explicit network filter, limit the results to the current network. + if ( empty( $prepared_args['network__in'] ) ) { + $prepared_args['network__in'] = array( get_current_network_id() ); + } + // WP_Site_Query tests the status columns with is_numeric(), and a boolean is not numeric. foreach ( array( 'public', 'archived', 'mature', 'spam', 'deleted' ) as $status_param ) { if ( isset( $prepared_args[ $status_param ] ) ) { diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 753f1a6f79180..e66dcc33ad31f 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -106,6 +106,50 @@ public function test_get_items() { $this->assertCount( 7, $sites ); } + /** + * Without an explicit network filter, only sites on the current network are returned. + * + * @ticket 40365 + * @covers ::get_items + * @group ms-required + */ + public function test_get_items_defaults_to_the_current_network() { + wp_set_current_user( self::$superadmin_id ); + + $current_network_site = self::factory()->blog->create( array( 'path' => '/current/' ) ); + + $other_network_id = self::factory()->network->create( + array( + 'domain' => 'other-network.example.org', + 'path' => '/', + ) + ); + + $other_network_site = self::factory()->blog->create( + array( + 'domain' => 'other-network.example.org', + 'path' => '/', + 'network_id' => $other_network_id, + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $site_ids = wp_list_pluck( $data, 'id' ); + + $this->assertContains( $current_network_site, $site_ids, 'Sites on the current network should be returned.' ); + $this->assertNotContains( $other_network_site, $site_ids, 'Sites on another network should not be returned.' ); + + $current_network_id = get_current_network_id(); + foreach ( $data as $site ) { + $this->assertSame( $current_network_id, $site['network'], 'Every returned site should belong to the current network.' ); + } + } + /** * @ticket 40365 * @covers ::get_item From addddc3217c2316e4fde527da12c3dca538f8196 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Mon, 5 Oct 2026 12:57:22 +0100 Subject: [PATCH 31/38] REST API: Limit the sites endpoint to the current network. Remove cross-network functionality from WP_REST_Sites_Controller: - Drop the `network` and `network_exclude` collection parameters and the `network__in` orderby; collections are always limited to the current network. - Make the `network` schema property read-only, so sites can no longer be created on, or moved to, another network. - Replace check_network_ids() with site_in_network(), filterable via the new `rest_site_in_network` filter ( bool $in_network, WP_Site $site, int $network_id ). - Get, update and delete permission checks return `rest_unable_read_from_network` for a site that is not on the current network, before capability checks. - Simplify check_url_is_available() now that network_id is never supplied. - Remove the network permission tests, add coverage for every CRUD route against a site on a second network and for the filter, and regenerate the API fixture. See #40365. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-rest-sites-controller.php | 134 ++--- .../tests/rest-api/wpRestSitesController.php | 501 ++++++++++-------- tests/qunit/fixtures/wp-api-generated.js | 31 +- 3 files changed, 321 insertions(+), 345 deletions(-) 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 6e2b1b94c3b95..ed230df32902d 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 @@ -133,16 +133,6 @@ public function get_items_permissions_check( $request ) { return $multisite_support; } - foreach ( array( 'network', 'network_exclude' ) as $network_key ) { - if ( ! isset( $request[ $network_key ] ) ) { - continue; - } - $network_check = $this->check_network_ids( (array) $request[ $network_key ] ); - if ( is_wp_error( $network_check ) ) { - return $network_check; - } - } - if ( $this->check_edit_permission() ) { return true; } @@ -203,8 +193,6 @@ public function get_items( $request ) { 'include' => 'site__in', 'offset' => 'offset', 'order' => 'order', - 'network' => 'network__in', - 'network_exclude' => 'network__not_in', 'per_page' => 'number', 'path' => 'path__in', 'path_exclude' => 'path__not_in', @@ -230,10 +218,8 @@ public function get_items( $request ) { } } - // Without an explicit network filter, limit the results to the current network. - if ( empty( $prepared_args['network__in'] ) ) { - $prepared_args['network__in'] = array( get_current_network_id() ); - } + // Only sites on the current network are exposed. + $prepared_args['network__in'] = array( get_current_network_id() ); // WP_Site_Query tests the status columns with is_numeric(), and a boolean is not numeric. foreach ( array( 'public', 'archived', 'mature', 'spam', 'deleted' ) as $status_param ) { @@ -260,7 +246,7 @@ public function get_items( $request ) { $orderby = $request['orderby']; // Ordering by an ID list needs a list to order by. - if ( in_array( $orderby, array( 'site__in', 'network__in' ), true ) && empty( $prepared_args[ $orderby ] ) ) { + if ( 'site__in' === $orderby && empty( $prepared_args['site__in'] ) ) { $orderby = 'id'; } @@ -426,11 +412,8 @@ public function get_item_permissions_check( $request ) { return $site; } - if ( $site->network_id > 0 ) { - $network_check = $this->check_network_ids( (array) $site->network_id ); - if ( is_wp_error( $network_check ) ) { - return $network_check; - } + if ( ! $this->site_in_network( $site ) ) { + return new WP_Error( 'rest_unable_read_from_network', __( 'Sorry, you are not allowed to view sites on another network.' ), array( 'status' => rest_authorization_required_code() ) ); } $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; @@ -447,42 +430,29 @@ public function get_item_permissions_check( $request ) { } /** - * Validates that the given network IDs exist and are accessible to the current user. - * - * A user can always access the current network. Access to any other - * network requires the user to be a super admin of that network. + * Checks whether a site belongs to the current network. * * @since 7.2.0 * - * @param int[] $network_ids Array of network IDs. - * @return true|WP_Error True if all network IDs exist and are accessible, WP_Error otherwise. + * @param WP_Site $site Site object. + * @return bool Whether the site is on the current network. */ - protected function check_network_ids( array $network_ids ) { - $current_network_id = get_current_network_id(); - $user_id = get_current_user_id(); - - _prime_network_caches( $network_ids ); - foreach ( $network_ids as $network_id ) { - if ( ! get_network( $network_id ) ) { - return new WP_Error( 'rest_network_id_invalid', __( 'Invalid network ID.' ), array( 'status' => 400 ) ); - } - } - - if ( count( $network_ids ) === 1 && $current_network_id === $network_ids[0] ) { - return true; - } - - foreach ( $network_ids as $network_id ) { - if ( (int) $network_id === $current_network_id ) { - continue; - } - - if ( ! is_super_admin( $user_id, $network_id ) ) { - return new WP_Error( 'rest_cannot_view_network', __( 'Sorry, you are not allowed to access sites on this network.' ), array( 'status' => rest_authorization_required_code() ) ); - } - } + protected function site_in_network( WP_Site $site ) { + $network_id = get_current_network_id(); - return true; + /** + * Filters whether a site is treated as belonging to the current network. + * + * Returning false blocks the site from being read, updated or deleted + * via the REST API. + * + * @since 7.2.0 + * + * @param bool $in_network Whether the site belongs to the current network. + * @param WP_Site $site The site being checked. + * @param int $network_id The current network ID. + */ + return (bool) apply_filters( 'rest_site_in_network', $network_id === $site->network_id, $site, $network_id ); } /** @@ -519,14 +489,6 @@ public function create_item_permissions_check( $request ) { return $multisite_support; } - $network_id = isset( $request['network'] ) ? (int) $request['network'] : get_current_network_id(); - if ( $network_id > 0 ) { - $network_check = $this->check_network_ids( (array) $network_id ); - if ( is_wp_error( $network_check ) ) { - return $network_check; - } - } - 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() ) ); } @@ -652,12 +614,8 @@ public function update_item_permissions_check( $request ) { return $site; } - $network_id = isset( $request['network'] ) ? (int) $request['network'] : $site->network_id; - if ( $network_id > 0 ) { - $network_check = $this->check_network_ids( (array) $network_id ); - if ( is_wp_error( $network_check ) ) { - return $network_check; - } + if ( ! $this->site_in_network( $site ) ) { + return new WP_Error( 'rest_unable_read_from_network', __( 'Sorry, you are not allowed to edit sites on another network.' ), array( 'status' => rest_authorization_required_code() ) ); } if ( ! $this->check_edit_permission() ) { @@ -755,11 +713,8 @@ public function delete_item_permissions_check( $request ) { return $site; } - if ( $site->network_id > 0 ) { - $network_check = $this->check_network_ids( (array) $site->network_id ); - if ( is_wp_error( $network_check ) ) { - return $network_check; - } + if ( ! $this->site_in_network( $site ) ) { + return new WP_Error( 'rest_unable_read_from_network', __( 'Sorry, you are not allowed to delete sites on another network.' ), array( 'status' => rest_authorization_required_code() ) ); } if ( ! $this->check_delete_permission( $site ) ) { @@ -1060,13 +1015,6 @@ protected function prepare_item_for_database( $request ) { } } - if ( isset( $request['network'] ) ) { - if ( ! get_network( $request['network'] ) ) { - return new WP_Error( 'rest_network_id_invalid', __( 'Invalid network ID.' ), array( 'status' => 400 ) ); - } - $prepared_site['network_id'] = (int) $request['network']; - } - if ( isset( $request['path'] ) ) { $prepared_site['path'] = $request['path']; } @@ -1265,7 +1213,7 @@ protected function check_url_is_available( $prepared_site, $request ) { $id = (int) $request['id']; $domain = isset( $prepared_site['domain'] ) ? $prepared_site['domain'] : ''; $path = isset( $prepared_site['path'] ) ? $prepared_site['path'] : '/'; - $network_id = isset( $prepared_site['network_id'] ) ? $prepared_site['network_id'] : get_current_network_id(); + $network_id = get_current_network_id(); if ( ! empty( $id ) ) { // Updating a site: fall back to the current values for anything the request left out. @@ -1281,9 +1229,7 @@ protected function check_url_is_available( $prepared_site, $request ) { if ( ! isset( $prepared_site['path'] ) ) { $path = $current_site->path; } - if ( ! isset( $prepared_site['network_id'] ) ) { - $network_id = (int) $current_site->network_id; - } + $network_id = (int) $current_site->network_id; } $existing_site_id = domain_exists( $domain, $path, $network_id ); @@ -1320,9 +1266,10 @@ public function get_item_schema() { 'readonly' => true, ), 'network' => array( - 'description' => __( 'The site\'s network ID. Default is the current network ID.' ), + 'description' => __( 'The site\'s network ID.' ), 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, ), 'domain' => array( 'description' => __( 'Site domain.' ), @@ -1546,7 +1493,6 @@ public function get_collection_params() { 'domain_length', 'path_length', 'site__in', - 'network__in', ), ); $query_params['user'] = array( @@ -1600,24 +1546,6 @@ public function get_collection_params() { 'format' => 'date-time', ); - $query_params['network'] = array( - 'default' => array(), - 'description' => __( 'Limit result set to sites of specific network IDs.' ), - 'type' => 'array', - 'items' => array( - 'type' => 'integer', - ), - ); - - $query_params['network_exclude'] = array( - 'default' => array(), - 'description' => __( 'Ensure result set excludes specific network IDs.' ), - 'type' => 'array', - 'items' => array( - 'type' => 'integer', - ), - ); - /** * Filter collection parameters for the sites controller. * diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index e66dcc33ad31f..2a81d6d43ea3f 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -107,7 +107,7 @@ public function test_get_items() { } /** - * Without an explicit network filter, only sites on the current network are returned. + * Only sites on the current network are returned. * * @ticket 40365 * @covers ::get_items @@ -482,44 +482,6 @@ public function test_create_item_rejects_an_existing_domain_and_path() { $this->assertErrorResponse( 'rest_site_taken', $response, 400 ); } - /** - * @ticket 40365 - * @covers ::create_item - * @group ms-required - */ - public function test_create_item_allows_an_existing_domain_and_path_on_a_different_network() { - wp_set_current_user( self::$superadmin_id ); - - self::factory()->blog->create( - array( - 'domain' => WP_TESTS_DOMAIN, - 'path' => '/shared/', - ) - ); - - $network_id = self::factory()->network->create( - array( - 'domain' => 'other-network.example.org', - 'path' => '/', - ) - ); - - $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); - $request->set_param( 'domain', WP_TESTS_DOMAIN ); - $request->set_param( 'path', '/shared/' ); - $request->set_param( 'network', $network_id ); - - $response = rest_get_server()->dispatch( $request ); - - $this->assertEquals( 201, $response->get_status() ); - - $data = $response->get_data(); - - $this->assertEquals( WP_TESTS_DOMAIN, $data['domain'] ); - $this->assertEquals( '/shared/', $data['path'] ); - $this->assertEquals( $network_id, $data['network'] ); - } - /** * @ticket 40365 * @covers ::update_item @@ -648,44 +610,6 @@ public function test_check_url_is_available_on_create( $candidate_domain, $candi } } - /** - * The same domain/path is available on a different network. - * - * @ticket 40365 - * @covers ::check_url_is_available - * @group ms-required - */ - public function test_check_url_is_available_allows_the_same_domain_and_path_on_a_different_network() { - self::factory()->blog->create( - array( - 'domain' => WP_TESTS_DOMAIN, - 'path' => '/check-url-shared/', - ) - ); - - $other_network_id = self::factory()->network->create( - array( - 'domain' => 'check-url-other-network.example', - 'path' => '/', - ) - ); - - $method = $this->get_reflective_method( 'check_url_is_available' ); - $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); - - $result = $method->invoke( - $this->endpoint, - array( - 'domain' => WP_TESTS_DOMAIN, - 'path' => '/check-url-shared/', - 'network_id' => $other_network_id, - ), - $request - ); - - $this->assertTrue( $result ); - } - /** * Data provider for test_check_url_is_available_on_update(). * @@ -1135,6 +1059,7 @@ public function test_get_item_schema() { $this->assertEqualSets( $expected, array_keys( $properties ) ); $this->assertTrue( $properties['id']['readonly'] ); + $this->assertTrue( $properties['network']['readonly'] ); $this->assertTrue( $properties['registered']['readonly'] ); $this->assertTrue( $properties['blogname']['readonly'] ); $this->assertEquals( 'boolean', $properties['public']['type'] ); @@ -1364,7 +1289,28 @@ public function test_update_item_does_not_accept_the_creation_fields() { $this->assertArrayNotHasKey( 'title', $args ); $this->assertArrayNotHasKey( 'user_id', $args ); + $this->assertArrayNotHasKey( 'network', $args ); + $this->assertArrayHasKey( 'domain', $args ); + } + + /** + * The network is read-only, so creating a site does not accept it either. + * + * @ticket 40365 + * @covers ::create_item + */ + public function test_create_item_does_not_accept_the_network_field() { + $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( 'domain', $args ); + $this->assertArrayNotHasKey( 'network', $args ); } /** @@ -1566,7 +1512,7 @@ public function test_get_items_are_ordered_ascending() { public function test_get_items_orderby_id_list_without_a_list() { wp_set_current_user( self::$superadmin_id ); - foreach ( array( 'site__in', 'network__in' ) as $orderby ) { + foreach ( array( 'site__in' ) as $orderby ) { $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); $request->set_param( 'orderby', $orderby ); @@ -2042,283 +1988,414 @@ public function test_get_items_filter_with_includes_user() { /** * @ticket 40365 - * @covers ::get_items_permissions_check - * @covers ::check_network_ids + * @covers ::update_item_permissions_check + * @covers ::site_in_network * @group ms-required */ - public function test_get_items_permissions_check_invalid_network_id() { - wp_set_current_user( self::factory()->user->create() ); + public function test_update_item_permissions_check_allows_the_sites_own_network_by_default() { + $blog_id = self::factory()->blog->create(); + wp_set_current_user( self::$superadmin_id ); - $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); - $request->set_param( 'network', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + $this->assertEquals( 200, $response->get_status() ); } /** * @ticket 40365 - * @covers ::get_items_permissions_check - * @covers ::check_network_ids + * @covers ::delete_item_permissions_check + * @covers ::site_in_network * @group ms-required */ - public function test_get_items_permissions_check_invalid_network_exclude_id() { - wp_set_current_user( self::factory()->user->create() ); + public function test_delete_item_permissions_check_invalid_network_id() { + $blog_id = self::factory()->blog->create(); - $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); - $request->set_param( 'network_exclude', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + // Orphan the site from its network to simulate an invalid stored network ID. + wp_update_site( $blog_id, array( 'network_id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); + + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + $this->assertErrorResponse( 'rest_unable_read_from_network', $response, 403 ); } /** * @ticket 40365 - * @covers ::get_items_permissions_check - * @covers ::check_network_ids + * @covers ::get_item_permissions_check + * @covers ::site_in_network * @group ms-required */ - public function test_get_items_permissions_check_denies_access_to_another_network() { - wp_set_current_user( self::factory()->user->create() ); - $network_id = self::factory()->network->create(); + public function test_get_item_permissions_check_allows_a_site_on_the_current_network() { + $blog_id = self::factory()->blog->create(); + $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( 'network', $network_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'id', $blog_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Creates a site on a network other than the current one. + * + * @return int[] The other network ID and the site ID. + */ + private function create_site_on_another_network() { + $network_id = self::factory()->network->create( + array( + 'domain' => 'other-network.example.org', + 'path' => '/', + ) + ); + + $blog_id = self::factory()->blog->create( + array( + 'domain' => 'other-network.example.org', + 'path' => '/elsewhere/', + 'network_id' => $network_id, + ) + ); + + return array( $network_id, $blog_id ); } /** * @ticket 40365 - * @covers ::get_items_permissions_check - * @covers ::check_network_ids + * @covers ::get_item_permissions_check + * @covers ::site_in_network * @group ms-required */ - public function test_get_items_permissions_check_allows_filtering_by_the_current_network() { - wp_set_current_user( self::factory()->user->create() ); - - $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); - $request->set_param( 'network', get_current_network_id() ); + public function test_get_item_on_another_network_is_forbidden() { + list( , $blog_id ) = $this->create_site_on_another_network(); + wp_set_current_user( self::$superadmin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); $response = rest_get_server()->dispatch( $request ); - // The user still lacks manage_sites, but the network filter itself is not the reason. - $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + $this->assertErrorResponse( 'rest_unable_read_from_network', $response, 403 ); } /** * @ticket 40365 - * @covers ::create_item_permissions_check - * @covers ::check_network_ids + * @covers ::update_item_permissions_check + * @covers ::site_in_network * @group ms-required */ - public function test_create_item_permissions_check_invalid_network_id() { - wp_set_current_user( self::factory()->user->create() ); + public function test_update_item_on_another_network_is_forbidden() { + list( , $blog_id ) = $this->create_site_on_another_network(); + 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', '/rejected/' ); - $request->set_param( 'network', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'path', '/moved/' ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + + $this->assertErrorResponse( 'rest_unable_read_from_network', $response, 403 ); + $this->assertSame( '/elsewhere/', get_site( $blog_id )->path, 'The site should be left unchanged.' ); } /** * @ticket 40365 - * @covers ::create_item_permissions_check - * @covers ::check_network_ids + * @covers ::delete_item_permissions_check + * @covers ::site_in_network * @group ms-required */ - public function test_create_item_permissions_check_denies_access_to_another_network() { - wp_set_current_user( self::factory()->user->create() ); - $network_id = self::factory()->network->create(); + public function test_delete_item_on_another_network_is_forbidden() { + list( , $blog_id ) = $this->create_site_on_another_network(); + 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', '/rejected/' ); - $request->set_param( 'network', $network_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request->set_param( 'force', true ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + + $this->assertErrorResponse( 'rest_unable_read_from_network', $response, 403 ); + $this->assertInstanceOf( 'WP_Site', get_site( $blog_id ), 'The site should still exist.' ); } /** * @ticket 40365 - * @covers ::create_item_permissions_check - * @covers ::check_network_ids + * @covers ::create_item * @group ms-required */ - public function test_create_item_permissions_check_allows_a_super_admin_of_the_target_network() { - $user = self::factory()->user->create_and_get(); - $network_id = self::factory()->network->create(); - update_network_option( $network_id, 'site_admins', array( $user->user_login ) ); - // create_sites is checked against the current network, so grant it there too. - grant_super_admin( $user->ID ); - wp_set_current_user( $user->ID ); + public function test_create_item_ignores_network_param() { + list( $network_id ) = $this->create_site_on_another_network(); + 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', '/ignores-network/' ); $request->set_param( 'network', $network_id ); - $this->assertTrue( $this->endpoint->create_item_permissions_check( $request ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertSame( get_current_network_id(), $data['network'] ); + $this->assertSame( get_current_network_id(), get_site( $data['id'] )->network_id ); } /** * @ticket 40365 - * @covers ::update_item_permissions_check - * @covers ::check_network_ids + * @covers ::update_item * @group ms-required */ - public function test_update_item_permissions_check_invalid_network_id() { - $blog_id = self::factory()->blog->create(); + public function test_update_item_ignores_network_param() { + list( $network_id ) = $this->create_site_on_another_network(); + $blog_id = self::factory()->blog->create( array( 'path' => '/stays-put/' ) ); wp_set_current_user( self::$superadmin_id ); $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); - $request->set_param( 'id', $blog_id ); - $request->set_param( 'network', REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $request->set_param( 'network', $network_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( get_current_network_id(), $response->get_data()['network'] ); + $this->assertSame( get_current_network_id(), get_site( $blog_id )->network_id ); } /** * @ticket 40365 - * @covers ::update_item_permissions_check - * @covers ::check_network_ids + * @covers ::get_items * @group ms-required */ - public function test_update_item_permissions_check_denies_moving_a_site_to_another_network() { - $blog_id = self::factory()->blog->create(); - $network_id = self::factory()->network->create(); - delete_network_option( $network_id, 'site_admins' ); + public function test_get_items_ignores_network_param() { + list( $network_id, $blog_id ) = $this->create_site_on_another_network(); wp_set_current_user( self::$superadmin_id ); - $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); - $request->set_param( 'id', $blog_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); $request->set_param( 'network', $network_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertNotEmpty( $data ); + $this->assertNotContains( $blog_id, wp_list_pluck( $data, 'id' ) ); + foreach ( $data as $site ) { + $this->assertSame( get_current_network_id(), $site['network'] ); + } + } + + /** + * Data provider for the single-site permission checks. + * + * @return array[] + */ + public function data_permission_checks_on_another_network() { + return array( + 'get' => array( 'GET', 'get_item_permissions_check' ), + 'update' => array( 'PUT', 'update_item_permissions_check' ), + 'delete' => array( 'DELETE', 'delete_item_permissions_check' ), + ); } /** + * Every permission check for a single site refuses a site on another network. + * * @ticket 40365 + * @covers ::get_item_permissions_check * @covers ::update_item_permissions_check - * @covers ::check_network_ids + * @covers ::delete_item_permissions_check + * @covers ::site_in_network * @group ms-required + * @dataProvider data_permission_checks_on_another_network + * + * @param string $method HTTP method. + * @param string $permission_check Permission check method name. */ - public function test_update_item_permissions_check_allows_the_sites_own_network_by_default() { - $blog_id = self::factory()->blog->create(); + public function test_permission_checks_block_a_site_on_another_network( $method, $permission_check ) { + list( , $blog_id ) = $this->create_site_on_another_network(); wp_set_current_user( self::$superadmin_id ); - $request = new WP_REST_Request( 'PUT', '/wp/v2/sites/' . $blog_id ); + $request = new WP_REST_Request( $method, '/wp/v2/sites/' . $blog_id ); $request->set_param( 'id', $blog_id ); - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 200, $response->get_status() ); + $result = $this->endpoint->$permission_check( $request ); + + $this->assertWPError( $result ); + $this->assertSame( 'rest_unable_read_from_network', $result->get_error_code() ); + $this->assertSame( 403, $result->get_error_data()['status'] ); } /** + * The same permission checks pass for a site on the current network. + * * @ticket 40365 + * @covers ::get_item_permissions_check + * @covers ::update_item_permissions_check * @covers ::delete_item_permissions_check - * @covers ::check_network_ids + * @covers ::site_in_network * @group ms-required + * @dataProvider data_permission_checks_on_another_network + * + * @param string $method HTTP method. + * @param string $permission_check Permission check method name. */ - public function test_delete_item_permissions_check_invalid_network_id() { - $blog_id = self::factory()->blog->create(); - - // Orphan the site from its network to simulate an invalid stored network ID. - wp_update_site( $blog_id, array( 'network_id' => REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) ); - + public function test_permission_checks_allow_a_site_on_the_current_network( $method, $permission_check ) { + $blog_id = self::factory()->blog->create( array( 'path' => '/current-network/' ) ); wp_set_current_user( self::$superadmin_id ); - $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); + $request = new WP_REST_Request( $method, '/wp/v2/sites/' . $blog_id ); $request->set_param( 'id', $blog_id ); - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_network_id_invalid', $response, 400 ); + $this->assertTrue( $this->endpoint->$permission_check( $request ) ); } /** + * Even a super admin of the second network is blocked from reaching its sites + * through the current network, for every method. + * * @ticket 40365 + * @covers ::get_item_permissions_check + * @covers ::update_item_permissions_check * @covers ::delete_item_permissions_check - * @covers ::check_network_ids * @group ms-required */ - public function test_delete_item_permissions_check_denies_access_to_another_network() { - $network_id = self::factory()->network->create(); - delete_network_option( $network_id, 'site_admins' ); - $blog_id = self::factory()->blog->create( array( 'site_id' => $network_id ) ); + public function test_super_admin_of_the_other_network_is_blocked() { + list( $network_id, $blog_id ) = $this->create_site_on_another_network(); + + $user = self::factory()->user->create_and_get(); + grant_super_admin( $user->ID ); + update_network_option( $network_id, 'site_admins', array( $user->user_login ) ); + wp_set_current_user( $user->ID ); + + foreach ( array( 'GET', 'PUT', 'PATCH', 'DELETE' ) as $method ) { + $request = new WP_REST_Request( $method, '/wp/v2/sites/' . $blog_id ); + if ( 'GET' !== $method && 'DELETE' !== $method ) { + $request->set_param( 'path', '/moved/' ); + } + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_unable_read_from_network', $response, 403 ); + } + + $site = get_site( $blog_id ); + $this->assertInstanceOf( 'WP_Site', $site, 'The site should still exist.' ); + $this->assertSame( '/elsewhere/', $site->path, 'The site should be left unchanged.' ); + $this->assertSame( $network_id, $site->network_id, 'The site should stay on its network.' ); + } + + /** + * Including a site from another network by ID does not expose it. + * + * @ticket 40365 + * @covers ::get_items + * @group ms-required + */ + public function test_get_items_include_does_not_expose_another_network() { + list( , $blog_id ) = $this->create_site_on_another_network(); wp_set_current_user( self::$superadmin_id ); - $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); - $request->set_param( 'id', $blog_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites' ); + $request->set_param( 'include', array( $blog_id ) ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + $this->assertSame( '0', $response->get_headers()['X-WP-Total'] ); } /** + * A super admin of the second network still creates sites on the current network. + * * @ticket 40365 - * @covers ::delete_item_permissions_check - * @covers ::check_network_ids + * @covers ::create_item + * @covers ::create_item_permissions_check * @group ms-required */ - public function test_delete_item_permissions_check_allows_a_super_admin_of_the_sites_network() { - $network_id = self::factory()->network->create(); - $user = self::factory()->user->create_and_get(); - update_network_option( $network_id, 'site_admins', array( $user->user_login ) ); - // delete_sites is checked against the current network, so grant it there too. - grant_super_admin( $user->ID ); - $blog_id = self::factory()->blog->create( array( 'site_id' => $network_id ) ); + public function test_create_item_by_admin_of_another_network_lands_on_the_current_network() { + list( $network_id ) = $this->create_site_on_another_network(); + $user = self::factory()->user->create_and_get(); + grant_super_admin( $user->ID ); + update_network_option( $network_id, 'site_admins', array( $user->user_login ) ); wp_set_current_user( $user->ID ); - $request = new WP_REST_Request( 'DELETE', '/wp/v2/sites/' . $blog_id ); - $request->set_param( 'id', $blog_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/sites' ); + $request->set_param( 'domain', 'other-network.example.org' ); + $request->set_param( 'path', '/elsewhere/' ); + $request->set_param( 'network', $network_id ); + + $response = rest_get_server()->dispatch( $request ); + + // The same domain and path exist on the other network, but not on this one. + $this->assertEquals( 201, $response->get_status() ); + + $data = $response->get_data(); - $this->assertTrue( $this->endpoint->delete_item_permissions_check( $request ) ); + $this->assertSame( get_current_network_id(), $data['network'] ); + $this->assertNotSame( $network_id, get_site( $data['id'] )->network_id ); } /** + * The rest_site_in_network filter can let a site on another network through. + * * @ticket 40365 - * @covers ::get_item_permissions_check - * @covers ::check_network_ids + * @covers ::site_in_network * @group ms-required */ - public function test_get_item_permissions_check_denies_a_site_on_another_network() { + public function test_site_in_network_filter_can_allow_another_network() { + list( $network_id, $blog_id ) = $this->create_site_on_another_network(); wp_set_current_user( self::$superadmin_id ); - $network_id = self::factory()->network->create(); - delete_network_option( $network_id, 'site_admins' ); - $blog_id = self::factory()->blog->create( array( 'site_id' => $network_id ) ); - wp_set_current_user( self::factory()->user->create() ); - - $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); - $request->set_param( 'id', $blog_id ); + $filter_args = array(); + add_filter( + 'rest_site_in_network', + static function ( $in_network, $site, $current_network_id ) use ( &$filter_args ) { + $filter_args = array( $in_network, $site, $current_network_id ); + return true; + }, + 10, + 3 + ); + $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_cannot_view_network', $response, 403 ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( $network_id, $response->get_data()['network'] ); + + list( $in_network, $site, $current_network_id ) = $filter_args; + $this->assertFalse( $in_network, 'The unfiltered value should be false for another network.' ); + $this->assertInstanceOf( 'WP_Site', $site ); + $this->assertSame( $blog_id, (int) $site->blog_id ); + $this->assertSame( get_current_network_id(), $current_network_id ); } /** + * The rest_site_in_network filter can block a site on the current network. + * * @ticket 40365 - * @covers ::get_item_permissions_check - * @covers ::check_network_ids + * @covers ::site_in_network * @group ms-required */ - public function test_get_item_permissions_check_allows_a_site_on_the_current_network() { - $blog_id = self::factory()->blog->create(); - $user_id = self::factory()->user->create(); - add_user_to_blog( $blog_id, $user_id, 'subscriber' ); - wp_set_current_user( $user_id ); + public function test_site_in_network_filter_can_block_the_current_network() { + $blog_id = self::factory()->blog->create( array( 'path' => '/filtered-out/' ) ); + wp_set_current_user( self::$superadmin_id ); - $request = new WP_REST_Request( 'GET', '/wp/v2/sites/' . $blog_id ); - $request->set_param( 'id', $blog_id ); + add_filter( 'rest_site_in_network', '__return_false' ); - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 200, $response->get_status() ); + foreach ( array( 'GET', 'PUT', 'DELETE' ) as $method ) { + $request = new WP_REST_Request( $method, '/wp/v2/sites/' . $blog_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_unable_read_from_network', $response, 403 ); + } } } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 99580f63b4e27..b22ff76829e1c 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -10998,8 +10998,7 @@ mockedApiResponse.Schema = { "registered", "domain_length", "path_length", - "site__in", - "network__in" + "site__in" ], "required": false }, @@ -11062,24 +11061,6 @@ mockedApiResponse.Schema = { "type": "string", "format": "date-time", "required": false - }, - "network": { - "default": [], - "description": "Limit result set to sites of specific network IDs.", - "type": "array", - "items": { - "type": "integer" - }, - "required": false - }, - "network_exclude": { - "default": [], - "description": "Ensure result set excludes specific network IDs.", - "type": "array", - "items": { - "type": "integer" - }, - "required": false } } }, @@ -11088,11 +11069,6 @@ mockedApiResponse.Schema = { "POST" ], "args": { - "network": { - "description": "The site's network ID. Default is the current network ID.", - "type": "integer", - "required": false - }, "domain": { "default": "", "description": "Site domain.", @@ -11207,11 +11183,6 @@ mockedApiResponse.Schema = { "type": "integer", "required": false }, - "network": { - "description": "The site's network ID. Default is the current network ID.", - "type": "integer", - "required": false - }, "domain": { "description": "Site domain.", "type": "string", From 9b4c6db7750de10c8036b91c421098b4eea4a512 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Mon, 5 Oct 2026 13:01:32 +0100 Subject: [PATCH 32/38] REST API: Use operation-specific error codes for cross-network site checks. The update and delete permission checks now return `rest_unable_update_from_network` and `rest_unable_delete_from_network` respectively, instead of `rest_unable_read_from_network`, for a site that is not on the current network. Tests now assert the code for each operation. See #40365. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-rest-sites-controller.php | 4 +- .../tests/rest-api/wpRestSitesController.php | 38 +++++++++++++------ 2 files changed, 28 insertions(+), 14 deletions(-) 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 ed230df32902d..e431f394492b7 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 @@ -615,7 +615,7 @@ public function update_item_permissions_check( $request ) { } if ( ! $this->site_in_network( $site ) ) { - return new WP_Error( 'rest_unable_read_from_network', __( 'Sorry, you are not allowed to edit sites on another network.' ), array( 'status' => rest_authorization_required_code() ) ); + return new WP_Error( 'rest_unable_update_from_network', __( 'Sorry, you are not allowed to edit sites on another network.' ), array( 'status' => rest_authorization_required_code() ) ); } if ( ! $this->check_edit_permission() ) { @@ -714,7 +714,7 @@ public function delete_item_permissions_check( $request ) { } if ( ! $this->site_in_network( $site ) ) { - return new WP_Error( 'rest_unable_read_from_network', __( 'Sorry, you are not allowed to delete sites on another network.' ), array( 'status' => rest_authorization_required_code() ) ); + return new WP_Error( 'rest_unable_delete_from_network', __( 'Sorry, you are not allowed to delete sites on another network.' ), array( 'status' => rest_authorization_required_code() ) ); } if ( ! $this->check_delete_permission( $site ) ) { diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 2a81d6d43ea3f..7c6855a813445 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -2021,7 +2021,7 @@ public function test_delete_item_permissions_check_invalid_network_id() { $request->set_param( 'id', $blog_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_unable_read_from_network', $response, 403 ); + $this->assertErrorResponse( 'rest_unable_delete_from_network', $response, 403 ); } /** @@ -2098,7 +2098,7 @@ public function test_update_item_on_another_network_is_forbidden() { $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_unable_read_from_network', $response, 403 ); + $this->assertErrorResponse( 'rest_unable_update_from_network', $response, 403 ); $this->assertSame( '/elsewhere/', get_site( $blog_id )->path, 'The site should be left unchanged.' ); } @@ -2117,7 +2117,7 @@ public function test_delete_item_on_another_network_is_forbidden() { $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_unable_read_from_network', $response, 403 ); + $this->assertErrorResponse( 'rest_unable_delete_from_network', $response, 403 ); $this->assertInstanceOf( 'WP_Site', get_site( $blog_id ), 'The site should still exist.' ); } @@ -2197,9 +2197,9 @@ public function test_get_items_ignores_network_param() { */ public function data_permission_checks_on_another_network() { return array( - 'get' => array( 'GET', 'get_item_permissions_check' ), - 'update' => array( 'PUT', 'update_item_permissions_check' ), - 'delete' => array( 'DELETE', 'delete_item_permissions_check' ), + 'get' => array( 'GET', 'get_item_permissions_check', 'rest_unable_read_from_network' ), + 'update' => array( 'PUT', 'update_item_permissions_check', 'rest_unable_update_from_network' ), + 'delete' => array( 'DELETE', 'delete_item_permissions_check', 'rest_unable_delete_from_network' ), ); } @@ -2216,8 +2216,9 @@ public function data_permission_checks_on_another_network() { * * @param string $method HTTP method. * @param string $permission_check Permission check method name. + * @param string $error_code Expected error code. */ - public function test_permission_checks_block_a_site_on_another_network( $method, $permission_check ) { + public function test_permission_checks_block_a_site_on_another_network( $method, $permission_check, $error_code ) { list( , $blog_id ) = $this->create_site_on_another_network(); wp_set_current_user( self::$superadmin_id ); @@ -2227,7 +2228,7 @@ public function test_permission_checks_block_a_site_on_another_network( $method, $result = $this->endpoint->$permission_check( $request ); $this->assertWPError( $result ); - $this->assertSame( 'rest_unable_read_from_network', $result->get_error_code() ); + $this->assertSame( $error_code, $result->get_error_code() ); $this->assertSame( 403, $result->get_error_data()['status'] ); } @@ -2273,7 +2274,14 @@ public function test_super_admin_of_the_other_network_is_blocked() { update_network_option( $network_id, 'site_admins', array( $user->user_login ) ); wp_set_current_user( $user->ID ); - foreach ( array( 'GET', 'PUT', 'PATCH', 'DELETE' ) as $method ) { + $error_codes = array( + 'GET' => 'rest_unable_read_from_network', + 'PUT' => 'rest_unable_update_from_network', + 'PATCH' => 'rest_unable_update_from_network', + 'DELETE' => 'rest_unable_delete_from_network', + ); + + foreach ( $error_codes as $method => $error_code ) { $request = new WP_REST_Request( $method, '/wp/v2/sites/' . $blog_id ); if ( 'GET' !== $method && 'DELETE' !== $method ) { $request->set_param( 'path', '/moved/' ); @@ -2281,7 +2289,7 @@ public function test_super_admin_of_the_other_network_is_blocked() { $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_unable_read_from_network', $response, 403 ); + $this->assertErrorResponse( $error_code, $response, 403 ); } $site = get_site( $blog_id ); @@ -2391,11 +2399,17 @@ public function test_site_in_network_filter_can_block_the_current_network() { add_filter( 'rest_site_in_network', '__return_false' ); - foreach ( array( 'GET', 'PUT', 'DELETE' ) as $method ) { + $error_codes = array( + 'GET' => 'rest_unable_read_from_network', + 'PUT' => 'rest_unable_update_from_network', + 'DELETE' => 'rest_unable_delete_from_network', + ); + + foreach ( $error_codes as $method => $error_code ) { $request = new WP_REST_Request( $method, '/wp/v2/sites/' . $blog_id ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_unable_read_from_network', $response, 403 ); + $this->assertErrorResponse( $error_code, $response, 403 ); } } } From 94133a0a73d01825348acc0aaace4886505f9961 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Mon, 5 Oct 2026 13:08:25 +0100 Subject: [PATCH 33/38] Users: Revert the network ID parameter for super admin checks. The sites endpoint no longer checks super admin status per network, so the `$network_id` parameter added to get_super_admins() and is_super_admin(), and the tests covering it, are no longer needed. Restore both functions and tests/phpunit/tests/user/capabilities.php to their trunk versions. See #40365. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/capabilities.php | 30 +----- tests/phpunit/tests/user/capabilities.php | 106 ---------------------- 2 files changed, 5 insertions(+), 131 deletions(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 8a9eb293ba268..6f8b57d50b348 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -1155,46 +1155,26 @@ function remove_role( $role ) { * Retrieves a list of super admins. * * @since 3.0.0 - * @since 7.2.0 Added the `$network_id` parameter. * * @global array $super_admins * - * @param int|null $network_id Optional. ID of the network. Default is the current network. * @return string[] List of super admin logins. */ -function get_super_admins( $network_id = null ) { +function get_super_admins() { global $super_admins; - // Falsy values (including 0) mean "unspecified", matching get_network_option()'s own fallback. - $is_current_network = ! $network_id || get_current_network_id() === (int) $network_id; - - if ( $is_current_network && isset( $super_admins ) ) { - return $super_admins; - } - - /* - * The 'admin' fallback exists for the current network only, where it reflects the - * install's original default super admin. A network whose `site_admins` option was - * never explicitly set has no default super admins - assuming 'admin' there would let - * any user with that login be treated as a super admin of a network they were never - * granted access to. - */ - $default = $is_current_network ? array( 'admin' ) : array(); - - return get_network_option( $network_id, 'site_admins', $default ); + return $super_admins ?? get_site_option( 'site_admins', array( 'admin' ) ); } /** * Determines whether user is a site admin. * * @since 3.0.0 - * @since 7.2.0 Added the `$network_id` parameter. * - * @param int|false $user_id Optional. The ID of a user. Defaults to false, to check the current user. - * @param int|null $network_id Optional. ID of the network. Default is the current network. + * @param int|false $user_id Optional. The ID of a user. Defaults to false, to check the current user. * @return bool Whether the user is a site admin. */ -function is_super_admin( $user_id = false, $network_id = null ) { +function is_super_admin( $user_id = false ) { if ( ! $user_id ) { $user = wp_get_current_user(); } else { @@ -1206,7 +1186,7 @@ function is_super_admin( $user_id = false, $network_id = null ) { } if ( is_multisite() ) { - $super_admins = get_super_admins( $network_id ); + $super_admins = get_super_admins(); if ( is_array( $super_admins ) && in_array( $user->user_login, $super_admins, true ) ) { return true; } diff --git a/tests/phpunit/tests/user/capabilities.php b/tests/phpunit/tests/user/capabilities.php index 7d7bc597dae25..34f46dfb1eec2 100644 --- a/tests/phpunit/tests/user/capabilities.php +++ b/tests/phpunit/tests/user/capabilities.php @@ -2590,110 +2590,4 @@ public function test_role_capabilities_updated_correctly_via_update_option() { $this->assertSameSetsWithIndex( $emcee_caps, $sally_caps, 'Emcee and Sally roles should have the same capabilities after update.' ); $this->assertLessThan( $emcee_queries, $sally_queries, 'Updating roles via update_option should be more efficient than WP_Roles using the database.' ); } - - /** - * @ticket 40365 - * @group ms-required - * - * @covers ::get_super_admins - */ - public function test_get_super_admins_with_explicit_current_network_id_matches_default() { - $this->assertSameSets( get_super_admins(), get_super_admins( get_current_network_id() ) ); - } - - /** - * @ticket 40365 - * @group ms-required - * - * @covers ::get_super_admins - */ - public function test_get_super_admins_for_another_network_returns_that_networks_site_admins() { - $network_id = self::factory()->network->create(); - update_network_option( $network_id, 'site_admins', array( 'someone-else' ) ); - - $this->assertSameSets( array( 'someone-else' ), get_super_admins( $network_id ) ); - $this->assertNotSame( get_super_admins(), get_super_admins( $network_id ) ); - } - - /** - * @ticket 40365 - * @group ms-required - * - * @covers ::get_super_admins - */ - public function test_get_super_admins_for_another_network_without_a_site_admins_option_is_empty() { - $network_id = self::factory()->network->create(); - delete_network_option( $network_id, 'site_admins' ); - - // Unlike the current network, a network with no `site_admins` option has no implicit 'admin' fallback. - $this->assertSame( array(), get_super_admins( $network_id ) ); - } - - /** - * @ticket 40365 - * @group ms-required - * - * @covers ::get_super_admins - */ - public function test_get_super_admins_global_override_does_not_apply_to_other_networks() { - $network_id = self::factory()->network->create(); - $had_global_override = array_key_exists( 'super_admins', $GLOBALS ); - $old_super_admins = $had_global_override ? $GLOBALS['super_admins'] : null; - delete_network_option( $network_id, 'site_admins' ); - update_network_option( $network_id, 'site_admins', array( 'network-admin' ) ); - - $GLOBALS['super_admins'] = array( 'global-override' ); - - $this->assertSameSets( array( 'global-override' ), get_super_admins() ); - $this->assertSameSets( array( 'network-admin' ), get_super_admins( $network_id ) ); - - if ( $had_global_override ) { - $GLOBALS['super_admins'] = $old_super_admins; - } else { - unset( $GLOBALS['super_admins'] ); - } - } - - /** - * @ticket 40365 - * @group ms-required - * - * @covers ::is_super_admin - */ - public function test_is_super_admin_defaults_to_the_current_network() { - $user = self::$super_admin; - - $this->assertTrue( is_super_admin( $user->ID ) ); - $this->assertTrue( is_super_admin( $user->ID, get_current_network_id() ) ); - } - - /** - * @ticket 40365 - * @group ms-required - * - * @covers ::is_super_admin - */ - public function test_is_super_admin_for_a_network_the_user_is_not_a_super_admin_of() { - $user = self::$super_admin; - $network_id = self::factory()->network->create(); - delete_network_option( $network_id, 'site_admins' ); - - $this->assertTrue( is_super_admin( $user->ID ), 'User is expected to be a super admin of the current network.' ); - $this->assertFalse( is_super_admin( $user->ID, $network_id ), 'User is not expected to be a super admin of an unrelated network.' ); - } - - /** - * @ticket 40365 - * @group ms-required - * - * @covers ::is_super_admin - */ - public function test_is_super_admin_for_a_network_the_user_is_a_super_admin_of() { - $user = self::factory()->user->create_and_get(); - $network_id = self::factory()->network->create(); - update_network_option( $network_id, 'site_admins', array( $user->user_login ) ); - - $this->assertFalse( is_super_admin( $user->ID ), 'User is not expected to be a super admin of the current network.' ); - $this->assertTrue( is_super_admin( $user->ID, $network_id ), 'User is expected to be a super admin of the network granting them access.' ); - } } From 686856f16afce11527195ca2952ccebe1a5af71d Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Wed, 7 Oct 2026 12:45:01 +0200 Subject: [PATCH 34/38] Fixed the existence leak --- .../class-wp-rest-sites-controller.php | 40 +++++++++---------- .../tests/rest-api/wpRestSitesController.php | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 21 deletions(-) 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 e431f394492b7..3f7e35dfe82e3 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 @@ -407,6 +407,14 @@ public function get_item_permissions_check( $request ) { return $multisite_support; } + // 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'] ); + + 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() ) ); + } + $site = $this->get_site( $request['id'] ); if ( is_wp_error( $site ) ) { return $site; @@ -416,16 +424,6 @@ public function get_item_permissions_check( $request ) { return new WP_Error( 'rest_unable_read_from_network', __( 'Sorry, you are not allowed to view sites on another network.' ), array( 'status' => rest_authorization_required_code() ) ); } - $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; - - if ( in_array( $context, array( 'view', 'embed' ), true ) && is_user_member_of_blog( get_current_user_id(), (int) $site->blog_id ) ) { - return true; - } - - if ( ! $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() ) ); - } - return true; } @@ -609,6 +607,10 @@ public function update_item_permissions_check( $request ) { return $multisite_support; } + 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() ) ); + } + $site = $this->get_site( $request['id'] ); if ( is_wp_error( $site ) ) { return $site; @@ -618,10 +620,6 @@ public function update_item_permissions_check( $request ) { return new WP_Error( 'rest_unable_update_from_network', __( 'Sorry, you are not allowed to edit sites on another network.' ), array( 'status' => rest_authorization_required_code() ) ); } - 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() ) ); - } - return true; } @@ -708,6 +706,10 @@ public function delete_item_permissions_check( $request ) { return $multisite_support; } + 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() ) ); + } + $site = $this->get_site( $request['id'] ); if ( is_wp_error( $site ) ) { return $site; @@ -717,10 +719,6 @@ public function delete_item_permissions_check( $request ) { return new WP_Error( 'rest_unable_delete_from_network', __( 'Sorry, you are not allowed to delete sites on another network.' ), array( 'status' => rest_authorization_required_code() ) ); } - if ( ! $this->check_delete_permission( $site ) ) { - return new WP_Error( 'rest_cannot_delete', __( 'Sorry, you are not allowed to delete this site.' ), array( 'status' => rest_authorization_required_code() ) ); - } - if ( get_main_site_id( (int) $site->site_id ) === (int) $site->blog_id ) { return new WP_Error( 'rest_cannot_delete_main_site', __( 'Sorry, the main site of a network cannot be deleted.' ), array( 'status' => 403 ) ); } @@ -1576,11 +1574,11 @@ protected function check_edit_permission() { * * @since 7.2.0 * - * @param object $site Site object. + * @param int $site_id Site ID. * @return bool Whether the site can be deleted. */ - protected function check_delete_permission( $site ) { - return current_user_can( 'delete_sites' ) && current_user_can( 'delete_site', $site->blog_id ); + protected function check_delete_permission( $site_id ) { + return current_user_can( 'delete_sites' ) && current_user_can( 'delete_site', $site_id ); } /** diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 7c6855a813445..a7bac5f3a57cf 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -431,6 +431,46 @@ public function test_get_item_invalid_id() { $this->assertErrorResponse( 'rest_site_invalid_id', $response, 404 ); } + /** + * Unauthorized requests get the same error for existing and unknown IDs, + * so they can't reveal which sites exist. + * + * @ticket 40365 + * @covers ::get_item_permissions_check + * @covers ::update_item_permissions_check + * @covers ::delete_item_permissions_check + * @group ms-required + * + * @dataProvider data_unauthorized_item_requests + * + * @param string $method HTTP method. + * @param string $expected_code Expected error code. + */ + public function test_unauthorized_item_request_does_not_reveal_site_existence( $method, $expected_code ) { + $site_id = self::factory()->blog->create(); + wp_set_current_user( 0 ); + + foreach ( array( $site_id, REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ) as $id ) { + $request = new WP_REST_Request( $method, '/wp/v2/sites/' . $id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( $expected_code, $response, 401 ); + } + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_unauthorized_item_requests() { + return array( + 'read' => array( 'GET', 'rest_forbidden_context' ), + 'update' => array( 'PUT', 'rest_cannot_edit' ), + 'delete' => array( 'DELETE', 'rest_cannot_delete' ), + ); + } + /** * @ticket 40365 * @covers ::create_item From be4cf149c14e548d26dd4fcd0473a593e1604362 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Wed, 7 Oct 2026 21:38:25 +0100 Subject: [PATCH 35/38] REST API: Limit the sites endpoint to the main site and tighten the response. Following the review on PR 13055, the sites endpoint now behaves like the network admin and is only usable from the network's main site: - Create, update, delete and single-site reads return a 403 `rest_cannot_{create,edit,delete,view}_not_on_main_site` error from any other site. - Listing your own sites (`user=me` or your own ID) and a member reading their site stay available from any site, but only in the view context. - Users listing their own sites cannot filter or order by fields outside the view context (`rest_forbidden_param`), so hidden values cannot be inferred. - The status flags, dates, `lang_id`, `post_count` and the site meta are now edit-context only. - Add a read-only `admin_url` field and build `home` and `siteurl` with get_home_url() and get_site_url(), so their filters apply. - Replace the write-only `title` property with a create-only, writable `blogname`, and move `user_id` out of the schema into the create route arguments. A POST to an existing site no longer runs the create path. - Skip the site queries when the user filter matches no sites, while still firing `rest_site_query`. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-rest-sites-controller.php | 160 +++- .../tests/rest-api/wpRestSiteMetaFields.php | 71 +- .../tests/rest-api/wpRestSitesController.php | 724 +++++++++++++++++- tests/qunit/fixtures/wp-api-generated.js | 4 +- 4 files changed, 892 insertions(+), 67 deletions(-) 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..67ee9d13a8a75 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,13 @@ 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_id'] = array( + 'description' => __( 'User ID of the site administrator, set when the site is created.' ), + 'type' => 'integer', + ); + register_rest_route( $this->namespace, '/' . $this->rest_base, @@ -66,16 +73,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 +140,54 @@ 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(); + + // A user may list their own sites from any site, but only in the view context. + if ( 'view' === $context && $this->is_own_user_filter( $request ) ) { + 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 ) ); + } + 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. * @@ -297,8 +340,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 +363,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'] ); @@ -409,7 +460,12 @@ 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'] ); + $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 +543,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 +667,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 +770,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 +883,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 +947,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,10 +1075,12 @@ 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'] ) ) { @@ -1291,28 +1370,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 +1401,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 +1449,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; 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..e69d7d1c63790 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -328,14 +328,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 +347,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 +571,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 +752,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 +1054,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 +1089,8 @@ public function test_get_item_schema() { 'blogname', 'siteurl', 'home', + 'admin_url', 'post_count', - 'title', - 'user_id', 'meta', ); @@ -1101,13 +1103,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 +1146,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 +1291,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( 'blogname', 'Voluptas' ); $request->set_param( 'user_id', $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']; @@ -1327,7 +1353,7 @@ public function test_update_item_does_not_accept_the_creation_fields() { } } - $this->assertArrayNotHasKey( 'title', $args ); + $this->assertArrayNotHasKey( 'blogname', $args ); $this->assertArrayNotHasKey( 'user_id', $args ); $this->assertArrayNotHasKey( 'network', $args ); $this->assertArrayHasKey( 'domain', $args ); @@ -1463,7 +1489,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 +1653,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 +1919,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() ); @@ -2452,4 +2481,665 @@ 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.' ); + + $denied = array( + 'member in edit context' => array( $member_id, $target_id, 'edit' ), + '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_id', $args ); + $this->assertSame( 'integer', $args['user_id']['type'] ); + $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_id', 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 ); + } + } + + /** + * 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'] ); + } } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index b22ff76829e1c..e34663864c60a 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11117,8 +11117,8 @@ 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 }, From 423bf96ea99d2553dbc1a76c1d56c68c3ef4e723 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Wed, 7 Oct 2026 21:44:23 +0100 Subject: [PATCH 36/38] REST API: Use a single validated user parameter for sites. The collection filter `user` and the create-only `user_id` argument described the same relationship under two names. Both are now `user`, sharing one schema that accepts a positive user ID or "me", and get_user_id_from_param() resolves "me" to the current user. - Invalid values such as `xyz`, `0`, `-1` or an empty string are now rejected with `rest_invalid_param` before the permission check. - Numeric strings are sanitized to integers. - An unknown administrator on creation returns `rest_user_invalid_id`. - Listing the sites of another user requires the capability to manage sites (`rest_forbidden_user`). Co-Authored-By: Claude Opus 5.5 --- .../class-wp-rest-sites-controller.php | 72 +++-- .../tests/rest-api/wpRestSitesController.php | 296 +++++++++++++++++- tests/qunit/fixtures/wp-api-generated.js | 38 ++- 3 files changed, 369 insertions(+), 37 deletions(-) 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 67ee9d13a8a75..5fbfc3847e35f 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 @@ -53,11 +53,8 @@ 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_id'] = array( - 'description' => __( 'User ID of the site administrator, set when the site is created.' ), - 'type' => 'integer', - ); + $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, @@ -143,6 +140,12 @@ public function get_items_permissions_check( $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $can_edit = $this->check_edit_permission(); + $user = $request['user']; + + // Only users who can manage sites may list the sites of another user. + if ( ! empty( $user ) && 'me' !== $user && get_current_user_id() !== $this->get_user_id_from_param( $user ) && ! $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() ) ); + } // A user may list their own sites from any site, but only in the view context. if ( 'view' === $context && $this->is_own_user_filter( $request ) ) { @@ -203,11 +206,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; } /** @@ -274,7 +311,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'] ) ) { @@ -1083,12 +1120,14 @@ protected function prepare_item_for_database( $request ) { $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; } } @@ -1571,10 +1610,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/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index e69d7d1c63790..9c43e735f6c7d 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -234,21 +234,21 @@ public function test_get_items_me_filter_forbidden_when_logged_out() { } /** - * 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 ); } /** @@ -1292,7 +1292,7 @@ public function test_create_item_sets_the_title_and_the_administrator() { $request->set_param( 'domain', WP_TESTS_DOMAIN ); $request->set_param( 'path', '/voluptas/' ); $request->set_param( 'blogname', 'Voluptas' ); - $request->set_param( 'user_id', $user_id ); + $request->set_param( 'user', $user_id ); $response = rest_get_server()->dispatch( $request ); @@ -1318,18 +1318,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/' ) ); @@ -1354,6 +1353,7 @@ public function test_update_item_does_not_accept_the_creation_fields() { } $this->assertArrayNotHasKey( 'blogname', $args ); + $this->assertArrayNotHasKey( 'user', $args ); $this->assertArrayNotHasKey( 'user_id', $args ); $this->assertArrayNotHasKey( 'network', $args ); $this->assertArrayHasKey( 'domain', $args ); @@ -2029,7 +2029,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 ); } /** @@ -2870,8 +2870,22 @@ public function test_create_item_accepts_the_creation_fields() { } $this->assertArrayHasKey( 'blogname', $args ); - $this->assertArrayHasKey( 'user_id', $args ); - $this->assertSame( 'integer', $args['user_id']['type'] ); + $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 ); } @@ -2895,7 +2909,15 @@ public function test_update_item_ignores_the_creation_fields() { 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_id', 99999 ); + $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 ); @@ -3142,4 +3164,248 @@ public function test_prepare_item_uses_the_filtered_site_addresses() { $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' ) ); + } } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index e34663864c60a..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": { @@ -11122,9 +11137,24 @@ mockedApiResponse.Schema = { "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 } } From e43b75bf0561fc12d0b2f7e1a8f3c0c7c6ea7122 Mon Sep 17 00:00:00 2001 From: Jonny Harrus Date: Wed, 7 Oct 2026 21:59:57 +0100 Subject: [PATCH 37/38] REST API: Require the capability to manage sites for the edit context. - Both read permission checks reject `context=edit` up front with `rest_forbidden_context` unless the user can manage sites, before the member, main-site and site lookup checks, so an edit-context request does not reveal whether a site exists. - get_items_permissions_check() resolves the own-user filter once and reuses it for the other-user check, so a logged-out `user=me` now returns `rest_forbidden_user`. - The check against filtering or ordering by fields outside the view context now applies to every user without the capability, and still runs before the own-sites exemption. Co-Authored-By: Claude Opus 5.5 --- .../class-wp-rest-sites-controller.php | 26 +++-- .../tests/rest-api/wpRestSitesController.php | 94 ++++++++++++++++++- 2 files changed, 110 insertions(+), 10 deletions(-) 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 5fbfc3847e35f..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 @@ -140,19 +140,24 @@ public function get_items_permissions_check( $request ) { $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $can_edit = $this->check_edit_permission(); - $user = $request['user']; + $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( $user ) && 'me' !== $user && get_current_user_id() !== $this->get_user_id_from_param( $user ) && ! $can_edit ) { + 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() ) ); } - // A user may list their own sites from any site, but only in the view context. - if ( 'view' === $context && $this->is_own_user_filter( $request ) ) { - 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 ) ); - } + // 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; } @@ -496,7 +501,12 @@ 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'; + $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. diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 9c43e735f6c7d..51fd4a1630507 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -230,7 +230,8 @@ 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 ); } /** @@ -2709,8 +2710,12 @@ public function test_get_item_permissions_check_off_the_main_site() { $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 edit context' => array( $member_id, $target_id, 'edit' ), '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' ), @@ -3408,4 +3413,89 @@ public function test_get_items_super_admin_can_filter_by_another_user() { $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 ); + } + } } From 75309a64d4ab7faa6f649d0001c24513f5dbfcb1 Mon Sep 17 00:00:00 2001 From: Dennis Ploetner Date: Sat, 10 Oct 2026 10:50:53 +0200 Subject: [PATCH 38/38] Fix Domain and Path maxlengths --- .../rest-api/endpoints/class-wp-rest-sites-controller.php | 6 +++++- tests/phpunit/tests/rest-api/wpRestSitesController.php | 6 ++++++ tests/qunit/fixtures/wp-api-generated.js | 4 ++++ 3 files changed, 15 insertions(+), 1 deletion(-) 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 f8d0d30f19189..801a13c6eb2e8 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 @@ -1240,7 +1240,7 @@ private function is_valid_domain( $domain ) { return 1 === preg_match( '/^(?!-)[A-Za-z0-9-]{1,63}(? 'string', 'context' => array( 'view', 'edit', 'embed' ), 'default' => '', + // The wp_blogs.domain column is varchar(200), port included. + 'maxLength' => 200, 'arg_options' => array( 'validate_callback' => array( $this, 'validate_domain' ), ), @@ -1411,6 +1413,8 @@ public function get_item_schema() { 'type' => 'string', 'context' => array( 'view', 'edit', 'embed' ), 'default' => '/', + // The wp_blogs.path column is varchar(100). + 'maxLength' => 100, 'arg_options' => array( 'validate_callback' => array( $this, 'validate_path' ), ), diff --git a/tests/phpunit/tests/rest-api/wpRestSitesController.php b/tests/phpunit/tests/rest-api/wpRestSitesController.php index 51fd4a1630507..b73805661184c 100644 --- a/tests/phpunit/tests/rest-api/wpRestSitesController.php +++ b/tests/phpunit/tests/rest-api/wpRestSitesController.php @@ -1799,6 +1799,9 @@ public function data_domain_validation() { 'empty label' => array( 'example..org', false ), 'port out of range' => array( 'example.org:99999', false ), 'non numeric port' => array( 'example.org:abc', false ), + // The wp_blogs.domain column is varchar(200). + 'domain at max length' => array( str_repeat( 'a', 63 ) . '.' . str_repeat( 'b', 63 ) . '.' . str_repeat( 'c', 63 ) . '.' . str_repeat( 'd', 8 ), true ), + 'domain over max length' => array( str_repeat( 'a', 63 ) . '.' . str_repeat( 'b', 63 ) . '.' . str_repeat( 'c', 63 ) . '.' . str_repeat( 'd', 9 ), false ), ); } @@ -1888,6 +1891,9 @@ public function data_path_validation() { 'path with a query string' => array( '/with?query/', false ), 'path with a fragment' => array( '/with#fragment/', false ), 'path with a double quote' => array( '/with"quote/', false ), + // The wp_blogs.path column is varchar(100). + 'path at max length' => array( '/' . str_repeat( 'a', 98 ) . '/', true ), + 'path over max length' => array( '/' . str_repeat( 'a', 99 ) . '/', false ), ); } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 3e0b4c9305821..2c29451e706e7 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11088,12 +11088,14 @@ mockedApiResponse.Schema = { "default": "", "description": "Site domain.", "type": "string", + "maxLength": 200, "required": false }, "path": { "default": "/", "description": "Site path.", "type": "string", + "maxLength": 100, "required": false }, "public": { @@ -11216,11 +11218,13 @@ mockedApiResponse.Schema = { "domain": { "description": "Site domain.", "type": "string", + "maxLength": 200, "required": false }, "path": { "description": "Site path.", "type": "string", + "maxLength": 100, "required": false }, "public": {