From b2cc1dfd70663178786a2b1ea56e6f2084fccb41 Mon Sep 17 00:00:00 2001 From: Timothy Jacobs Date: Wed, 1 Jul 2020 04:22:25 +0000 Subject: [PATCH] REST API: Introduce plugin management and block directory endpoints. These endpoints facilitate the Block Directory Inserter feature in Gutenberg. Users can now install, activate, deactivate, and delete plugins over the REST API. The block directoryendpoint allows searching for available blocks from the WordPress.org block directory. Props cklee, talldanwp, noisysocks, joen, soean, youknowriad, dufresnesteven, gziolo, dd32, tellyworth, ryelle, spacedmonkey, TimothyBlynJacobs. Fixes #50321. git-svn-id: https://develop.svn.wordpress.org/trunk@48242 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/edit-form-blocks.php | 2 + src/wp-admin/includes/plugin-install.php | 19 +- src/wp-includes/rest-api.php | 8 + ...ass-wp-rest-block-directory-controller.php | 345 +++++ .../class-wp-rest-plugins-controller.php | 948 ++++++++++++++ src/wp-settings.php | 2 + tests/phpunit/data/plugins/link-manager.zip | Bin 0 -> 953 bytes .../rest-block-directory-controller.php | 297 +++++ .../rest-api/rest-plugins-controller.php | 1127 +++++++++++++++++ .../tests/rest-api/rest-schema-setup.php | 3 + tests/qunit/fixtures/wp-api-generated.js | 210 +++ 11 files changed, 2953 insertions(+), 8 deletions(-) create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-block-directory-controller.php create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php create mode 100644 tests/phpunit/data/plugins/link-manager.zip create mode 100644 tests/phpunit/tests/rest-api/rest-block-directory-controller.php create mode 100644 tests/phpunit/tests/rest-api/rest-plugins-controller.php diff --git a/src/wp-admin/edit-form-blocks.php b/src/wp-admin/edit-form-blocks.php index 1451710b78..6156163d6c 100644 --- a/src/wp-admin/edit-form-blocks.php +++ b/src/wp-admin/edit-form-blocks.php @@ -361,12 +361,14 @@ wp_enqueue_media( ); wp_tinymce_inline_scripts(); wp_enqueue_editor(); +wp_enqueue_script( 'wp-block-directory' ); /** * Styles */ wp_enqueue_style( 'wp-edit-post' ); wp_enqueue_style( 'wp-format-library' ); +wp_enqueue_style( 'wp-block-directory' ); /** * Fires after block assets have been enqueued for the editing interface. diff --git a/src/wp-admin/includes/plugin-install.php b/src/wp-admin/includes/plugin-install.php index 0354d9a5ac..9b35e8fd41 100644 --- a/src/wp-admin/includes/plugin-install.php +++ b/src/wp-admin/includes/plugin-install.php @@ -173,14 +173,17 @@ function plugins_api( $action, $args = array() ) { $request = wp_remote_get( $url, $http_args ); if ( $ssl && is_wp_error( $request ) ) { - trigger_error( - sprintf( - /* translators: %s: Support forums URL. */ - __( 'An unexpected error occurred. Something may be wrong with WordPress.org or this server’s configuration. If you continue to have problems, please try the support forums.' ), - __( 'https://wordpress.org/support/forums/' ) - ) . ' ' . __( '(WordPress could not establish a secure connection to WordPress.org. Please contact your server administrator.)' ), - headers_sent() || WP_DEBUG ? E_USER_WARNING : E_USER_NOTICE - ); + if ( ! wp_is_json_request() ) { + trigger_error( + sprintf( + /* translators: %s: Support forums URL. */ + __( 'An unexpected error occurred. Something may be wrong with WordPress.org or this server’s configuration. If you continue to have problems, please try the support forums.' ), + __( 'https://wordpress.org/support/forums/' ) + ) . ' ' . __( '(WordPress could not establish a secure connection to WordPress.org. Please contact your server administrator.)' ), + headers_sent() || WP_DEBUG ? E_USER_WARNING : E_USER_NOTICE + ); + } + $request = wp_remote_get( $http_url, $http_args ); } diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 8e7631754c..bb846cac26 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -285,6 +285,14 @@ function create_initial_rest_routes() { $controller = new WP_REST_Themes_Controller; $controller->register_routes(); + // Plugins. + $controller = new WP_REST_Plugins_Controller(); + $controller->register_routes(); + + // Block Directory. + $controller = new WP_REST_Block_Directory_Controller(); + $controller->register_routes(); + } /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-block-directory-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-block-directory-controller.php new file mode 100644 index 0000000000..bc81da184a --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-block-directory-controller.php @@ -0,0 +1,345 @@ +namespace = 'wp/v2'; + $this->rest_base = 'block-directory'; + } + + /** + * Registers the necessary REST API routes. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/search', + 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(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks whether a given request has permission to install and activate plugins. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|bool True if the request has permission, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + if ( ! current_user_can( 'install_plugins' ) || ! current_user_can( 'activate_plugins' ) ) { + return new WP_Error( + 'rest_block_directory_cannot_view', + __( 'Sorry, you are not allowed to browse the block directory.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Search and retrieve blocks metadata + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $response = plugins_api( + 'query_plugins', + array( + 'block' => $request['term'], + 'per_page' => $request['per_page'], + 'page' => $request['page'], + ) + ); + + if ( is_wp_error( $response ) ) { + $response->add_data( array( 'status' => 500 ) ); + + return $response; + } + + $result = array(); + + foreach ( $response->plugins as $plugin ) { + $data = $this->prepare_item_for_response( $plugin, $request ); + $result[] = $this->prepare_response_for_collection( $data ); + } + + return rest_ensure_response( $result ); + } + + /** + * Parse block metadata for a block, and prepare it for an API repsonse. + * + * @since 5.5.0 + * + * @param array $plugin The plugin metadata. + * @param WP_REST_Request $request Request object. + * + * @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $plugin, $request ) { + // There might be multiple blocks in a plugin. Only the first block is mapped. + $block_data = reset( $plugin['blocks'] ); + + // A data array containing the properties we'll return. + $block = array( + 'name' => $block_data['name'], + 'title' => ( $block_data['title'] ? $block_data['title'] : $plugin['name'] ), + 'description' => wp_trim_words( $plugin['description'], 30, '...' ), + 'id' => $plugin['slug'], + 'rating' => $plugin['rating'] / 20, + 'rating_count' => intval( $plugin['num_ratings'] ), + 'active_installs' => intval( $plugin['active_installs'] ), + 'author_block_rating' => $plugin['author_block_rating'] / 20, + 'author_block_count' => intval( $plugin['author_block_count'] ), + 'author' => wp_strip_all_tags( $plugin['author'] ), + 'icon' => ( isset( $plugin['icons']['1x'] ) ? $plugin['icons']['1x'] : 'block-default' ), + 'assets' => array(), + 'last_updated' => gmdate( 'Y-m-d\TH:i:s', strtotime( $plugin['last_updated'] ) ), + 'humanized_updated' => sprintf( + /* translators: %s: Human-readable time difference. */ + __( '%s ago' ), + human_time_diff( strtotime( $plugin['last_updated'] ) ) + ), + ); + + foreach ( $plugin['block_assets'] as $asset ) { + // Allow for fully qualified URLs in future + if ( 'https' === wp_parse_url( $asset, PHP_URL_SCHEME ) && ! empty( wp_parse_url( $asset, PHP_URL_HOST ) ) ) { + $block['assets'][] = esc_url_raw( + $asset, + array( 'https' ) + ); + } else { + $block['assets'][] = esc_url_raw( + add_query_arg( 'v', strtotime( $block['last_updated'] ), 'https://ps.w.org/' . $plugin['slug'] . $asset ), + array( 'https' ) + ); + } + } + + $this->add_additional_fields_to_object( $block, $request ); + + $response = new WP_REST_Response( $block ); + $response->add_links( $this->prepare_links( $plugin ) ); + + return $response; + } + + /** + * Generates a list of links to include in the response for the plugin. + * + * @since 5.5.0 + * + * @param array $plugin The plugin data from WordPress.org. + * + * @return array + */ + protected function prepare_links( $plugin ) { + $links = array( + 'https://api.w.org/install-plugin' => array( + 'href' => add_query_arg( 'slug', urlencode( $plugin['slug'] ), rest_url( 'wp/v2/plugins' ) ), + ), + ); + + $plugin_file = $this->find_plugin_for_slug( $plugin['slug'] ); + + if ( $plugin_file ) { + $links['https://api.w.org/plugin'] = array( + 'href' => rest_url( 'wp/v2/plugins/' . substr( $plugin_file, 0, - 4 ) ), + 'embeddable' => true, + ); + } + + return $links; + } + + /** + * Finds an installed plugin for the given slug. + * + * @since 5.5.0 + * + * @param string $slug The WordPress.org directory slug for a plugin. + * + * @return string The plugin file found matching it. + */ + protected function find_plugin_for_slug( $slug ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $plugin_files = get_plugins( '/' . $slug ); + + if ( ! $plugin_files ) { + return ''; + } + + $plugin_files = array_keys( $plugin_files ); + + return $slug . '/' . reset( $plugin_files ); + } + + /** + * Retrieves the theme's schema, conforming to JSON Schema. + * + * @since 5.5.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $this->schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'block-directory-item', + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'description' => __( 'The block name, in namespace/block-name format.' ), + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'title' => array( + 'description' => __( 'The block title, in human readable format.' ), + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'description' => array( + 'description' => __( 'A short description of the block, in human readable format.' ), + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'id' => array( + 'description' => __( 'The block slug.' ), + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'rating' => array( + 'description' => __( 'The star rating of the block.' ), + 'type' => 'integer', + 'context' => array( 'view' ), + ), + 'rating_count' => array( + 'description' => __( 'The number of ratings.' ), + 'type' => 'integer', + 'context' => array( 'view' ), + ), + 'active_installs' => array( + 'description' => __( 'The number sites that have activated this block.' ), + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'author_block_rating' => array( + 'description' => __( 'The average rating of blocks published by the same author.' ), + 'type' => 'integer', + 'context' => array( 'view' ), + ), + 'author_block_count' => array( + 'description' => __( 'The number of blocks published by the same author.' ), + 'type' => 'integer', + 'context' => array( 'view' ), + ), + 'author' => array( + 'description' => __( 'The WordPress.org username of the block author.' ), + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'icon' => array( + 'description' => __( 'The block icon.' ), + 'type' => 'string', + 'format' => 'uri', + 'context' => array( 'view' ), + ), + 'last_updated' => array( + 'description' => __( 'The date when the block was last updated, in fuzzy human readable format.' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'view' ), + ), + 'humanized_updated' => array( + 'description' => __( 'The date when the block was last updated, in fuzzy human readable format.' ), + 'type' => 'string', + 'context' => array( 'view' ), + ), + 'assets' => array( + 'description' => __( 'An object representing the block CSS and JavaScript assets.' ), + 'type' => 'array', + 'context' => array( 'view' ), + 'readonly' => true, + 'items' => array( + 'type' => 'string', + 'format' => 'uri', + ), + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the search params for the blocks collection. + * + * @since 5.5.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + $query_params['context']['default'] = 'view'; + + $query_params['term'] = array( + 'description' => __( 'Limit result set to blocks matching the search term.' ), + 'type' => 'string', + 'required' => true, + 'minLength' => 1, + ); + + unset( $query_params['search'] ); + + /** + * Filter collection parameters for the block directory controller. + * + * @since 5.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_block_directory_collection_params', $query_params ); + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php new file mode 100644 index 0000000000..5654879ee5 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-plugins-controller.php @@ -0,0 +1,948 @@ +namespace = 'wp/v2'; + $this->rest_base = 'plugins'; + } + + /** + * Registers the routes for the plugins controller. + * + * @since 5.5.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' => array( + 'slug' => array( + 'type' => 'string', + 'required' => true, + 'description' => __( 'WordPress.org plugin directory slug.' ), + 'pattern' => '[\w\-]+', + ), + 'status' => array( + 'description' => __( 'The plugin activation status.' ), + 'type' => 'string', + 'enum' => is_multisite() ? array( 'inactive', 'active', 'network-active' ) : array( 'inactive', 'active' ), + 'default' => 'inactive', + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P' . self::PATTERN . ')', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_item' ), + 'permission_callback' => array( $this, 'update_item_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'plugin' => array( + 'type' => 'string', + 'pattern' => self::PATTERN, + 'validate_callback' => array( $this, 'validate_plugin_param' ), + 'sanitize_callback' => array( $this, 'sanitize_plugin_param' ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to get plugins. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + if ( ! current_user_can( 'activate_plugins' ) ) { + return new WP_Error( + 'rest_cannot_view_plugins', + __( 'Sorry, you are not allowed to manage plugins for this site.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Retrieves a collection of plugins. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $plugins = array(); + + foreach ( get_plugins() as $file => $data ) { + if ( is_wp_error( $this->check_read_permission( $file ) ) ) { + continue; + } + + $data['_file'] = $file; + + if ( ! $this->does_plugin_match_request( $request, $data ) ) { + continue; + } + + $plugins[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $data, $request ) ); + } + + return new WP_REST_Response( $plugins ); + } + + /** + * Checks if a given request has access to get a specific plugin. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + if ( ! current_user_can( 'activate_plugins' ) ) { + return new WP_Error( + 'rest_cannot_view_plugin', + __( 'Sorry, you are not allowed to manage plugins for this site.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $can_read = $this->check_read_permission( $request['plugin'] ); + + if ( is_wp_error( $can_read ) ) { + return $can_read; + } + + return true; + } + + /** + * Retrieves one plugin from the site. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $data = $this->get_plugin_data( $request['plugin'] ); + + if ( is_wp_error( $data ) ) { + return $data; + } + + return $this->prepare_item_for_response( $data, $request ); + } + + /** + * Checks if the given plugin can be viewed by the current user. + * + * On multisite, this hides non-active network only plugins if the user does not have permission + * to manage network plugins. + * + * @since 5.5.0 + * + * @param string $plugin The plugin file to check. + * @return true|WP_Error True if can read, a WP_Error instance otherwise. + */ + protected function check_read_permission( $plugin ) { + if ( ! $this->is_plugin_installed( $plugin ) ) { + return new WP_Error( 'rest_plugin_not_found', __( 'Plugin not found.' ), array( 'status' => 404 ) ); + } + + if ( ! is_multisite() ) { + return true; + } + + if ( ! is_network_only_plugin( $plugin ) || is_plugin_active( $plugin ) || current_user_can( 'manage_network_plugins' ) ) { + return true; + } + + return new WP_Error( + 'rest_cannot_view_plugin', + __( 'Sorry, you are not allowed to manage this plugin.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Checks if a given request has access to upload plugins. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise. + */ + public function create_item_permissions_check( $request ) { + if ( ! current_user_can( 'install_plugins' ) ) { + return new WP_Error( + 'rest_cannot_install_plugin', + __( 'Sorry, you are not allowed to install plugins on this site.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( 'inactive' !== $request['status'] && ! current_user_can( 'activate_plugins' ) ) { + return new WP_Error( + 'rest_cannot_activate_plugin', + __( 'Sorry, you are not allowed to activate plugins.' ), + array( + 'status' => rest_authorization_required_code(), + ) + ); + } + + return true; + } + + /** + * Uploads a plugin and optionally activates it. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function create_item( $request ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + + $slug = $request['slug']; + + // Verify filesystem is accessible first. + $filesystem_available = $this->is_filesystem_available(); + if ( is_wp_error( $filesystem_available ) ) { + return $filesystem_available; + } + + $api = plugins_api( + 'plugin_information', + array( + 'slug' => $slug, + 'fields' => array( + 'sections' => false, + ), + ) + ); + + if ( is_wp_error( $api ) ) { + if ( false !== strpos( $api->get_error_message(), 'Plugin not found.' ) ) { + $api->add_data( array( 'status' => 404 ) ); + } else { + $api->add_data( array( 'status' => 500 ) ); + } + + return $api; + } + + $skin = new WP_Ajax_Upgrader_Skin(); + $upgrader = new Plugin_Upgrader( $skin ); + + $result = $upgrader->install( $api->download_link ); + + if ( is_wp_error( $result ) ) { + $result->add_data( array( 'status' => 500 ) ); + + return $result; + } + + // This should be the same as $result above. + if ( is_wp_error( $skin->result ) ) { + $skin->result->add_data( array( 'status' => 500 ) ); + + return $skin->result; + } + + if ( $skin->get_errors()->has_errors() ) { + $error = $skin->get_errors(); + $error->add_data( array( 'status' => 500 ) ); + + return $error; + } + + if ( is_null( $result ) ) { + global $wp_filesystem; + // Pass through the error from WP_Filesystem if one was raised. + if ( $wp_filesystem instanceof WP_Filesystem_Base && is_wp_error( $wp_filesystem->errors ) && $wp_filesystem->errors->has_errors() ) { + return new WP_Error( 'unable_to_connect_to_filesystem', $wp_filesystem->errors->get_error_message(), array( 'status' => 500 ) ); + } + + return new WP_Error( 'unable_to_connect_to_filesystem', __( 'Unable to connect to the filesystem. Please confirm your credentials.' ), array( 'status' => 500 ) ); + } + + $file = $upgrader->plugin_info(); + + if ( ! $file ) { + return new WP_Error( 'unable_to_determine_installed_plugin', __( 'Unable to determine what plugin was installed.' ), array( 'status' => 500 ) ); + } + + if ( 'inactive' !== $request['status'] ) { + $can_change_status = $this->plugin_status_permission_check( $file, $request['status'], 'inactive' ); + + if ( is_wp_error( $can_change_status ) ) { + return $can_change_status; + } + + $changed_status = $this->handle_plugin_status( $file, $request['status'], 'inactive' ); + + if ( is_wp_error( $changed_status ) ) { + return $changed_status; + } + } + + $path = WP_PLUGIN_DIR . '/' . $file; + $data = get_plugin_data( $path, false, false ); + $data['_file'] = $file; + + $response = $this->prepare_item_for_response( $data, $request ); + $response->set_status( 201 ); + $response->header( 'Location', rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, substr( $file, 0, - 4 ) ) ) ); + + return $response; + } + + /** + * Checks if a given request has access to update a specific plugin. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to update the item, WP_Error object otherwise. + */ + public function update_item_permissions_check( $request ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + if ( ! current_user_can( 'activate_plugins' ) ) { + return new WP_Error( + 'rest_cannot_manage_plugins', + __( 'Sorry, you are not allowed to manage plugins for this site.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $can_read = $this->check_read_permission( $request['plugin'] ); + + if ( is_wp_error( $can_read ) ) { + return $can_read; + } + + $status = $this->get_plugin_status( $request['plugin'] ); + + if ( $request['status'] && $status !== $request['status'] ) { + $can_change_status = $this->plugin_status_permission_check( $request['plugin'], $request['status'], $status ); + + if ( is_wp_error( $can_change_status ) ) { + return $can_change_status; + } + } + + return true; + } + + /** + * Updates one plugin. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function update_item( $request ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $data = $this->get_plugin_data( $request['plugin'] ); + + if ( is_wp_error( $data ) ) { + return $data; + } + + $status = $this->get_plugin_status( $request['plugin'] ); + + if ( $request['status'] && $status !== $request['status'] ) { + $handled = $this->handle_plugin_status( $request['plugin'], $request['status'], $status ); + + if ( is_wp_error( $handled ) ) { + return $handled; + } + } + + $this->update_additional_fields_for_object( $data, $request ); + + $request['context'] = 'edit'; + + return $this->prepare_item_for_response( $data, $request ); + } + + /** + * Checks if a given request has access to delete a specific plugin. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + if ( ! current_user_can( 'activate_plugins' ) ) { + return new WP_Error( + 'rest_cannot_manage_plugins', + __( 'Sorry, you are not allowed to manage plugins for this site.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( ! current_user_can( 'delete_plugins' ) ) { + return new WP_Error( + 'rest_cannot_manage_plugins', + __( 'Sorry, you are not allowed to delete plugins for this site.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $can_read = $this->check_read_permission( $request['plugin'] ); + + if ( is_wp_error( $can_read ) ) { + return $can_read; + } + + return true; + } + + /** + * Deletes one plugin from the site. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function delete_item( $request ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $data = $this->get_plugin_data( $request['plugin'] ); + + if ( is_wp_error( $data ) ) { + return $data; + } + + if ( is_plugin_active( $request['plugin'] ) ) { + return new WP_Error( + 'rest_cannot_delete_active_plugin', + __( 'Cannot delete an active plugin. Please deactivate it first.' ), + array( 'status' => 400 ) + ); + } + + $filesystem_available = $this->is_filesystem_available(); + if ( is_wp_error( $filesystem_available ) ) { + return $filesystem_available; + } + + $prepared = $this->prepare_item_for_response( $data, $request ); + $deleted = delete_plugins( array( $request['plugin'] ) ); + + if ( is_wp_error( $deleted ) ) { + $deleted->add_data( array( 'status' => 500 ) ); + + return $deleted; + } + + return new WP_REST_Response( + array( + 'deleted' => true, + 'previous' => $prepared->get_data(), + ) + ); + } + + /** + * Prepares the plugin for the REST response. + * + * @since 5.5.0 + * + * @param mixed $item Unmarked up and untranslated plugin data from {@see get_plugin_data()}. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { + $item = _get_plugin_data_markup_translate( $item['_file'], $item, false ); + $marked = _get_plugin_data_markup_translate( $item['_file'], $item, true ); + + $data = array( + 'plugin' => substr( $item['_file'], 0, - 4 ), + 'status' => $this->get_plugin_status( $item['_file'] ), + 'name' => $item['Name'], + 'plugin_uri' => $item['PluginURI'], + 'author' => $item['Author'], + 'author_uri' => $item['AuthorURI'], + 'description' => array( + 'raw' => $item['Description'], + 'rendered' => $marked['Description'], + ), + 'version' => $item['Version'], + 'network_only' => $item['Network'], + 'requires_wp' => $item['RequiresWP'], + 'requires_php' => $item['RequiresPHP'], + 'text_domain' => $item['TextDomain'], + ); + + $data = $this->add_additional_fields_to_object( $data, $request ); + + $response = new WP_REST_Response( $data ); + $response->add_links( $this->prepare_links( $item ) ); + + /** + * Filters the plugin data for a response. + * + * @since 5.5.0 + * + * @param WP_REST_Response $response The response object. + * @param array $item The plugin item from {@see get_plugin_data()}. + * @param WP_REST_Request $request The request object. + */ + return apply_filters( 'rest_prepare_plugin', $response, $item, $request ); + } + + /** + * Prepares links for the request. + * + * @since 5.5.0 + * + * @param array $item The plugin item. + * @return array[] + */ + protected function prepare_links( $item ) { + return array( + 'self' => array( + 'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, substr( $item['_file'], 0, - 4 ) ) ), + ), + ); + } + + /** + * Gets the plugin header data for a plugin. + * + * @since 5.5.0 + * + * @param string $plugin The plugin file to get data for. + * @return array|WP_Error The plugin data, or a WP_Error if the plugin is not installed. + */ + protected function get_plugin_data( $plugin ) { + $plugins = get_plugins(); + + if ( ! isset( $plugins[ $plugin ] ) ) { + return new WP_Error( 'rest_plugin_not_found', __( 'Plugin not found.' ), array( 'status' => 404 ) ); + } + + $data = $plugins[ $plugin ]; + $data['_file'] = $plugin; + + return $data; + } + + /** + * Get's the activation status for a plugin. + * + * @since 5.5.0 + * + * @param string $plugin The plugin file to check. + * @return string Either 'network-active', 'active' or 'inactive'. + */ + protected function get_plugin_status( $plugin ) { + if ( is_plugin_active_for_network( $plugin ) ) { + return 'network-active'; + } + + if ( is_plugin_active( $plugin ) ) { + return 'active'; + } + + return 'inactive'; + } + + /** + * Handle updating a plugin's status. + * + * @since 5.5.0 + * + * @param string $plugin The plugin file to update. + * @param string $new_status The plugin's new status. + * @param string $current_status The plugin's current status. + * + * @return true|WP_Error + */ + protected function plugin_status_permission_check( $plugin, $new_status, $current_status ) { + if ( is_multisite() && ( 'network-active' === $current_status || 'network-active' === $new_status ) && ! current_user_can( 'manage_network_plugins' ) ) { + return new WP_Error( + 'rest_cannot_manage_network_plugins', + __( 'Sorry, you do not have permission to manage network plugins.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( ( 'active' === $new_status || 'network-active' === $new_status ) && ! current_user_can( 'activate_plugin', $plugin ) ) { + return new WP_Error( + 'rest_cannot_activate_plugin', + __( 'Sorry, you are not allowed to activate this plugin.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( 'inactive' === $new_status && ! current_user_can( 'deactivate_plugin', $plugin ) ) { + return new WP_Error( + 'rest_cannot_deactivate_plugin', + __( 'Sorry, you are not allowed to deactivate this plugin.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Handle updating a plugin's status. + * + * @since 5.5.0 + * + * @param string $plugin The plugin file to update. + * @param string $new_status The plugin's new status. + * @param string $current_status The plugin's current status. + * @return true|WP_Error + */ + protected function handle_plugin_status( $plugin, $new_status, $current_status ) { + if ( 'inactive' === $new_status ) { + deactivate_plugins( $plugin, false, 'network-active' === $current_status ); + + return true; + } + + if ( 'active' === $new_status && 'network-active' === $current_status ) { + return true; + } + + $network_activate = 'network-active' === $new_status; + + if ( is_multisite() && ! $network_activate && is_network_only_plugin( $plugin ) ) { + return new WP_Error( + 'rest_network_only_plugin', + __( 'Network only plugin must be network activated.' ), + array( 'status' => 400 ) + ); + } + + $activated = activate_plugin( $plugin, '', $network_activate ); + + if ( is_wp_error( $activated ) ) { + $activated->add_data( array( 'status' => 500 ) ); + + return $activated; + } + + return true; + } + + /** + * Checks that the "plugin" parameter is a valid path. + * + * @since 5.5.0 + * + * @param string $file The plugin file parameter. + * @return bool + */ + public function validate_plugin_param( $file ) { + if ( ! is_string( $file ) || ! preg_match( '/' . self::PATTERN . '/u', $file ) ) { + return false; + } + + $validated = validate_file( plugin_basename( $file ) ); + + return 0 === $validated; + } + + /** + * Sanitizes the "plugin" parameter to be a proper plugin file with ".php" appended. + * + * @since 5.5.0 + * + * @param string $file The plugin file parameter. + * @return string + */ + public function sanitize_plugin_param( $file ) { + return plugin_basename( sanitize_text_field( $file . '.php' ) ); + } + + /** + * Checks if the plugin matches the requested parameters. + * + * @since 5.5.0 + * + * @param WP_REST_Request $request The request to require the plugin matches against. + * @param array $item The plugin item. + * + * @return bool + */ + protected function does_plugin_match_request( $request, $item ) { + $search = $request['search']; + + if ( $search ) { + $matched_search = false; + + foreach ( $item as $field ) { + if ( is_string( $field ) && false !== strpos( strip_tags( $field ), $search ) ) { + $matched_search = true; + break; + } + } + + if ( ! $matched_search ) { + return false; + } + } + + $status = $request['status']; + + if ( $status && ! in_array( $this->get_plugin_status( $item['_file'] ), $status, true ) ) { + return false; + } + + return true; + } + + /** + * Checks if the plugin is installed. + * + * @since 5.5.0 + * + * @param string $plugin The plugin file. + * @return bool + */ + protected function is_plugin_installed( $plugin ) { + return file_exists( WP_PLUGIN_DIR . '/' . $plugin ); + } + + /** + * Determine if the endpoints are available. + * + * Only the 'Direct' filesystem transport, and SSH/FTP when credentials are stored are supported at present. + * + * @since 5.5.0 + * + * @return true|WP_Error True if filesystem is available, WP_Error otherwise. + */ + protected function is_filesystem_available() { + $filesystem_method = get_filesystem_method(); + + if ( 'direct' === $filesystem_method ) { + return true; + } + + ob_start(); + $filesystem_credentials_are_stored = request_filesystem_credentials( self_admin_url() ); + ob_end_clean(); + + if ( $filesystem_credentials_are_stored ) { + return true; + } + + return new WP_Error( 'fs_unavailable', __( 'The filesystem is currently unavailable for managing plugins.' ), array( 'status' => 500 ) ); + } + + /** + * Retrieves the plugin's schema, conforming to JSON Schema. + * + * @since 4.7.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $this->schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'plugin', + 'type' => 'object', + 'properties' => array( + 'plugin' => array( + 'description' => __( 'The plugin file.' ), + 'type' => 'string', + 'pattern' => self::PATTERN, + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'status' => array( + 'description' => __( 'The plugin activation status.' ), + 'type' => 'string', + 'enum' => is_multisite() ? array( 'inactive', 'active', 'network-active' ) : array( 'inactive', 'active' ), + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'name' => array( + 'description' => __( 'The plugin name.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'plugin_uri' => array( + 'description' => __( 'The plugin\'s website address.' ), + 'type' => 'string', + 'format' => 'uri', + 'readonly' => true, + 'context' => array( 'view', 'edit' ), + ), + 'author' => array( + 'description' => __( 'The plugin author.' ), + 'type' => 'object', + 'readonly' => true, + 'context' => array( 'view', 'edit' ), + ), + 'author_uri' => array( + 'description' => __( 'Plugin author\'s website address.' ), + 'type' => 'string', + 'format' => 'uri', + 'readonly' => true, + 'context' => array( 'view', 'edit' ), + ), + 'description' => array( + 'description' => __( 'The plugin description.' ), + 'type' => 'object', + 'readonly' => true, + 'context' => array( 'view', 'edit' ), + 'properties' => array( + 'raw' => array( + 'description' => __( 'The raw plugin description.' ), + 'type' => 'string', + ), + 'rendered' => array( + 'description' => __( 'The plugin description formatted for display.' ), + 'type' => 'string', + ), + ), + ), + 'version' => array( + 'description' => __( 'The plugin version number.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit' ), + ), + 'network_only' => array( + 'description' => __( 'Whether the plugin can only be activated network-wide.' ), + 'type' => 'boolean', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'requires_wp' => array( + 'description' => __( 'Minimum required version of WordPress.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'requires_php' => array( + 'description' => __( 'Minimum required version of PHP.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + 'text_domain' => array( + 'description' => __( 'The plugin\'s text domain.' ), + 'type' => 'string', + 'readonly' => true, + 'context' => array( 'view', 'edit' ), + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the query params for the collections. + * + * @since 5.5.0 + * + * @return array Query parameters for the collection. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + $query_params['context']['default'] = 'view'; + + $query_params['status'] = array( + 'description' => __( 'Limits results to plugins with the given status.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => is_multisite() ? array( 'inactive', 'active', 'network-active' ) : array( 'inactive', 'active' ), + ), + ); + + unset( $query_params['page'], $query_params['per_page'] ); + + return $query_params; + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index b221012bcd..02d80702e3 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -257,6 +257,8 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-block-types-control require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-block-renderer-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-settings-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-themes-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-plugins-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-block-directory-controller.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php'; diff --git a/tests/phpunit/data/plugins/link-manager.zip b/tests/phpunit/data/plugins/link-manager.zip new file mode 100644 index 0000000000000000000000000000000000000000..788eae362618b42ce41a9124bf3c4c9ae8b09026 GIT binary patch literal 953 zcmWIWW@h1H00GtQ0*+t?l;8!@IhlFcy19vYiRr0D`T;Cfa z7-Z3vBJ0yD$S4Tycl2g96lrM5!5v#fshaHg-E6)SJX@^44rpIHj5+#72w6lQCk zTBav+*55^9#^gzxx?7q~mOKi2oY~Xuu{Y)V+zC$~7j3PWx9C_@ZOOceX&1r*j^7r1 z`?{=l-nQ&Lb2q)U-4Z2Sv~a;pu5AU|^XeE;!$aw5i(dvXEP|OB7{t-TqbM~oB{x;C zq@pBsqGR?Q1A*4}zeL|(V~ff4e0lVN_v|3CZ30eS=Qr7Acb@TDXA+hEzcxL0*OD(P zhO^AyU!J}0bpgjmR{NL|;nb4Y^nJ@TEWd;X>FF+#b}~7icgZ?CJM$Xza^H&!o|`;9 zk~8l`IqyuaISnU8Ge4fJGdmyB;&w%H(za8IJR;m%fBdVkxUZbLLHJbt>mNV2l< zRWqz?6M3F`t;!egDJhwHG^zfGYG_6IGTnHcd5@x2e_pZEgyU16r0YaUD>q4_$j?hp z-7?*=!}524LGj!#%e@wQUiDcO`}Sc);q;=(<_k9OYPotR$1Qy7?XbdbHQD3nwPqMQ%c{s1F~kx7mjcghCF5D*A3yalIuuser->create( + array( + 'role' => 'administrator', + ) + ); + + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + } + + /** + * @ticket 50321 + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/wp/v2/block-directory/search', $routes ); + } + + /** + * @ticket 50321 + */ + public function test_context_param() { + // Collection. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/block-directory/search' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertEquals( array( 'view' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + /** + * @ticket 50321 + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/block-directory/search' ); + $request->set_query_params( array( 'term' => 'foo' ) ); + + $result = rest_do_request( $request ); + $this->assertNotWPError( $result->as_error() ); + $this->assertEquals( 200, $result->status ); + } + + /** + * @ticket 50321 + */ + public function test_get_items_wdotorg_unavailable() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/block-directory/search' ); + $request->set_query_params( array( 'term' => 'foo' ) ); + + $this->prevent_requests_to_host( 'api.wordpress.org' ); + + $this->expectException( 'PHPUnit_Framework_Error_Warning' ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'plugins_api_failed', $response, 500 ); + } + + /** + * @ticket 50321 + */ + public function test_get_items_logged_out() { + $request = new WP_REST_Request( 'GET', '/wp/v2/block-directory/search' ); + $request->set_query_params( array( 'term' => 'foo' ) ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_block_directory_cannot_view', $response ); + } + + /** + * @ticket 50321 + */ + public function test_get_items_no_results() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/block-directory/search' ); + $request->set_query_params( array( 'term' => '0c4549ee68f24eaaed46a49dc983ecde' ) ); + $response = rest_do_request( $request ); + $data = $response->get_data(); + + // Should produce a 200 status with an empty array. + $this->assertEquals( 200, $response->status ); + $this->assertEquals( array(), $data ); + } + + public function test_get_item() { + $this->markTestSkipped( 'Controller does not have get_item route.' ); + } + + public function test_create_item() { + $this->markTestSkipped( 'Controller does not have create_item route.' ); + } + + public function test_update_item() { + $this->markTestSkipped( 'Controller does not have update_item route.' ); + } + + public function test_delete_item() { + $this->markTestSkipped( 'Controller does not have delete_item route.' ); + } + + /** + * @ticket 50321 + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + + $controller = new WP_REST_Block_Directory_Controller(); + + $plugin = $this->get_mock_plugin(); + $request = new WP_REST_Request( 'GET', '/wp/v2/block-directory/search' ); + $request->set_query_params( array( 'term' => 'block' ) ); + + $response = $controller->prepare_item_for_response( $plugin, $request ); + + $expected = array( + 'name' => 'sortabrilliant/guidepost', + 'title' => 'Guidepost', + 'description' => 'A guidepost gives you directions. It lets you know where you’re going. It gives you a preview of what’s to come. How does it work? Guideposts are magic, no they...', + 'id' => 'guidepost', + 'rating' => 4.3, + 'rating_count' => 90, + 'active_installs' => 100, + 'author_block_rating' => 0, + 'author_block_count' => 1, + 'author' => 'sorta brilliant', + 'icon' => 'https://ps.w.org/guidepost/assets/icon-128x128.jpg?rev=2235512', + 'assets' => array( + 'https://ps.w.org/guidepost/tags/1.2.1/build/index.js?v=1584940380', + 'https://ps.w.org/guidepost/tags/1.2.1/build/guidepost-editor.css?v=1584940380', + 'https://ps.w.org/guidepost/tags/1.2.1/build/guidepost-style.css?v=1584940380', + 'https://ps.w.org/guidepost/tags/1.2.1/build/guidepost-theme.js?v=1584940380', + ), + 'last_updated' => '2020-03-23T05:13:00', + 'humanized_updated' => '3 months ago', + ); + + $this->assertEquals( $expected, $response->get_data() ); + } + + /** + * @ticket 50321 + */ + public function test_get_item_schema() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/block-directory/search' ); + $request->set_query_params( array( 'term' => 'foo' ) ); + $response = rest_do_request( $request ); + $data = $response->get_data(); + + // Check endpoints + $this->assertEquals( array( 'GET' ), $data['endpoints'][0]['methods'] ); + $this->assertTrue( $data['endpoints'][0]['args']['term']['required'] ); + + $properties = $data['schema']['properties']; + + $this->assertCount( 14, $properties ); + $this->assertArrayHasKey( 'name', $properties ); + $this->assertArrayHasKey( 'title', $properties ); + $this->assertArrayHasKey( 'description', $properties ); + $this->assertArrayHasKey( 'id', $properties ); + $this->assertArrayHasKey( 'rating', $properties ); + $this->assertArrayHasKey( 'rating_count', $properties ); + $this->assertArrayHasKey( 'active_installs', $properties ); + $this->assertArrayHasKey( 'author_block_rating', $properties ); + $this->assertArrayHasKey( 'author_block_count', $properties ); + $this->assertArrayHasKey( 'author', $properties ); + $this->assertArrayHasKey( 'icon', $properties ); + $this->assertArrayHasKey( 'last_updated', $properties ); + $this->assertArrayHasKey( 'humanized_updated', $properties ); + $this->assertArrayHasKey( 'assets', $properties ); + } + + /** + * Simulate a network failure on outbound http requests to a given hostname. + * + * @since 5.5.0 + * + * @param string $blocked_host The host to block connections to. + */ + private function prevent_requests_to_host( $blocked_host = 'api.wordpress.org' ) { + add_filter( + 'pre_http_request', + static function ( $return, $args, $url ) use ( $blocked_host ) { + if ( @parse_url( $url, PHP_URL_HOST ) === $blocked_host ) { + return new WP_Error( 'plugins_api_failed', "An expected error occurred connecting to $blocked_host because of a unit test", "cURL error 7: Failed to connect to $blocked_host port 80: Connection refused" ); + + } + + return $return; + }, + 10, + 3 + ); + } + + /** + * Gets an example of the data returned from the {@see plugins_api()} for a block. + * + * @since 5.5.0 + * + * @return array + */ + private function get_mock_plugin() { + return array( + 'name' => 'Guidepost', + 'slug' => 'guidepost', + 'version' => '1.2.1', + 'author' => 'sorta brilliant', + 'author_profile' => 'https://profiles.wordpress.org/sortabrilliant', + 'requires' => '5.0', + 'tested' => '5.4.0', + 'requires_php' => '5.6', + 'rating' => 86, + 'ratings' => array( + 5 => 50, + 4 => 25, + 3 => 7, + 2 => 5, + 1 => 3, + ), + 'num_ratings' => 90, + 'support_threads' => 1, + 'support_threads_resolved' => 0, + 'active_installs' => 100, + 'downloaded' => 1112, + 'last_updated' => '2020-03-23 5:13am GMT', + 'added' => '2020-01-29', + 'homepage' => 'https://sortabrilliant.com/guidepost/', + 'description' => '

A guidepost gives you directions. It lets you know where you’re going. It gives you a preview of what’s to come. How does it work? Guideposts are magic, no they really are.

', + 'short_description' => 'A guidepost gives you directions. It lets you know where you’re going. It gives you a preview of what’s to come.', + 'download_link' => 'https://downloads.wordpress.org/plugin/guidepost.1.2.1.zip', + 'tags' => array( + 'block' => 'block', + 'heading' => 'heading', + 'style' => 'style', + ), + 'donate_link' => '', + 'icons' => array( + '1x' => 'https://ps.w.org/guidepost/assets/icon-128x128.jpg?rev=2235512', + '2x' => 'https://ps.w.org/guidepost/assets/icon-256x256.jpg?rev=2235512', + ), + 'blocks' => array( + 'sortabrilliant/guidepost' => array( + 'name' => 'sortabrilliant/guidepost', + 'title' => 'Guidepost', + ), + ), + 'block_assets' => array( + 0 => '/tags/1.2.1/build/index.js', + 1 => '/tags/1.2.1/build/guidepost-editor.css', + 2 => '/tags/1.2.1/build/guidepost-style.css', + 3 => '/tags/1.2.1/build/guidepost-theme.js', + ), + 'author_block_count' => 1, + 'author_block_rating' => 0, + ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-plugins-controller.php b/tests/phpunit/tests/rest-api/rest-plugins-controller.php new file mode 100644 index 0000000000..26ed4cc4c0 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-plugins-controller.php @@ -0,0 +1,1127 @@ +user->create( + array( + 'role' => 'subscriber', + ) + ); + self::$super_admin = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + self::$admin = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + + if ( is_multisite() ) { + grant_super_admin( self::$super_admin ); + } + } + + /** + * Clean up test fixtures. + * + * @since 5.5.0 + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$subscriber_id ); + self::delete_user( self::$super_admin ); + self::delete_user( self::$admin ); + } + + public function tearDown() { + parent::tearDown(); + + if ( file_exists( WP_PLUGIN_DIR . '/test-plugin/test-plugin.php' ) ) { + $this->rmdir( WP_PLUGIN_DIR . '/test-plugin' ); + } + } + + /** + * @ticket 50321 + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( self::BASE, $routes ); + $this->assertArrayHasKey( self::BASE . '/(?P[^.\/]+(?:\/[^.\/]+)?)', $routes ); + } + + /** + * @ticket 50321 + */ + public function test_context_param() { + // Collection. + $request = new WP_REST_Request( 'OPTIONS', self::BASE ); + $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. + $request = new WP_REST_Request( 'OPTIONS', self::BASE . '/' . self::PLUGIN ); + $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'] ); + } + + /** + * @ticket 50321 + */ + public function test_get_items() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + + $response = rest_do_request( self::BASE ); + $this->assertEquals( 200, $response->get_status() ); + + $items = wp_list_filter( $response->get_data(), array( 'plugin' => self::PLUGIN ) ); + + $this->assertCount( 1, $items ); + $this->check_get_plugin_data( array_shift( $items ) ); + } + + /** + * @ticket 50321 + */ + public function test_get_items_search() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'GET', self::BASE ); + $request->set_query_params( array( 'search' => 'testeroni' ) ); + $response = rest_do_request( $request ); + $this->assertCount( 0, $response->get_data() ); + + $request = new WP_REST_Request( 'GET', self::BASE ); + $request->set_query_params( array( 'search' => 'Cool' ) ); + $response = rest_do_request( $request ); + $this->assertCount( 1, wp_list_filter( $response->get_data(), array( 'plugin' => self::PLUGIN ) ) ); + } + + /** + * @ticket 50321 + */ + public function test_get_items_status() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'GET', self::BASE ); + $request->set_query_params( array( 'status' => 'inactive' ) ); + $response = rest_do_request( $request ); + $this->assertCount( 1, wp_list_filter( $response->get_data(), array( 'plugin' => self::PLUGIN ) ) ); + + $request = new WP_REST_Request( 'GET', self::BASE ); + $request->set_query_params( array( 'status' => 'active' ) ); + $response = rest_do_request( $request ); + $this->assertCount( 0, wp_list_filter( $response->get_data(), array( 'plugin' => self::PLUGIN ) ) ); + } + + /** + * @ticket 50321 + */ + public function test_get_items_status_multiple() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'GET', self::BASE ); + $request->set_query_params( array( 'status' => array( 'inactive', 'active' ) ) ); + $response = rest_do_request( $request ); + + $this->assertGreaterThan( 0, count( wp_list_filter( $response->get_data(), array( 'plugin' => self::PLUGIN ), 'NOT' ) ) ); + $this->assertCount( 1, wp_list_filter( $response->get_data(), array( 'plugin' => self::PLUGIN ) ) ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_get_items_status_network_active() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'GET', self::BASE ); + $request->set_query_params( array( 'status' => 'network-active' ) ); + $response = rest_do_request( $request ); + $this->assertCount( 0, wp_list_filter( $response->get_data(), array( 'plugin' => self::PLUGIN ) ) ); + + activate_plugin( self::PLUGIN_FILE, '', true ); + $request = new WP_REST_Request( 'GET', self::BASE ); + $request->set_query_params( array( 'status' => 'network-active' ) ); + $response = rest_do_request( $request ); + $this->assertCount( 1, wp_list_filter( $response->get_data(), array( 'plugin' => self::PLUGIN ) ) ); + } + + /** + * @ticket 50321 + */ + public function test_get_items_logged_out() { + $response = rest_do_request( self::BASE ); + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * @ticket 50321 + */ + public function test_get_items_insufficient_permissions() { + wp_set_current_user( self::$subscriber_id ); + $response = rest_do_request( self::BASE ); + $this->assertequals( 403, $response->get_status() ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_cannot_get_items_if_plugins_menu_not_available() { + $this->create_test_plugin(); + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'GET', self::BASE ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_cannot_view_plugins', $response->as_error(), 403 ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_get_items_if_plugins_menu_available() { + $this->create_test_plugin(); + $this->enable_plugins_menu_item(); + wp_set_current_user( self::$admin ); + + $response = rest_do_request( self::BASE ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_get_items_excludes_network_only_plugin_if_not_active() { + $this->create_test_plugin( true ); + $this->enable_plugins_menu_item(); + wp_set_current_user( self::$admin ); + + $response = rest_do_request( self::BASE ); + $this->assertEquals( 200, $response->get_status() ); + + $items = wp_list_filter( $response->get_data(), array( 'plugin' => self::PLUGIN ) ); + $this->assertCount( 0, $items ); + } + + /** + * @group ms-excluded + * @ticket 50321 + */ + public function test_get_items_does_not_exclude_network_only_plugin_if_not_active_on_single_site() { + $this->create_test_plugin( true ); + wp_set_current_user( self::$admin ); + + $response = rest_do_request( self::BASE ); + $this->assertEquals( 200, $response->get_status() ); + + $items = wp_list_filter( $response->get_data(), array( 'plugin' => self::PLUGIN ) ); + $this->assertCount( 1, $items ); + $this->check_get_plugin_data( array_shift( $items ), true ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_get_items_does_not_exclude_network_only_plugin_if_not_active_but_has_network_caps() { + $this->create_test_plugin( true ); + $this->enable_plugins_menu_item(); + wp_set_current_user( self::$super_admin ); + + $response = rest_do_request( self::BASE ); + $this->assertEquals( 200, $response->get_status() ); + + $items = wp_list_filter( $response->get_data(), array( 'plugin' => self::PLUGIN ) ); + $this->assertCount( 1, $items ); + $this->check_get_plugin_data( array_shift( $items ), true ); + } + + /** + * @ticket 50321 + */ + public function test_get_item() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + + $response = rest_do_request( self::BASE . '/' . self::PLUGIN ); + $this->assertEquals( 200, $response->get_status() ); + $this->check_get_plugin_data( $response->get_data() ); + } + + /** + * @ticket 50321 + */ + public function test_get_item_logged_out() { + $response = rest_do_request( self::BASE . '/' . self::PLUGIN ); + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * @ticket 50321 + */ + public function test_get_item_insufficient_permissions() { + wp_set_current_user( self::$subscriber_id ); + $response = rest_do_request( self::BASE . '/' . self::PLUGIN ); + $this->assertEquals( 403, $response->get_status() ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_cannot_get_item_if_plugins_menu_not_available() { + $this->create_test_plugin(); + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'GET', self::BASE . '/' . self::PLUGIN ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_cannot_view_plugin', $response->as_error(), 403 ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_get_item_if_plugins_menu_available() { + $this->create_test_plugin(); + $this->enable_plugins_menu_item(); + wp_set_current_user( self::$admin ); + + $response = rest_do_request( self::BASE . '/' . self::PLUGIN ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * @ticket 50321 + */ + public function test_get_item_invalid_plugin() { + wp_set_current_user( self::$super_admin ); + $response = rest_do_request( self::BASE . '/' . self::PLUGIN ); + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * @ticket 50321 + */ + public function test_create_item() { + if ( isset( get_plugins()['link-manager/link-manager.php'] ) ) { + delete_plugins( array( 'link-manager/link-manager.php' ) ); + } + + wp_set_current_user( self::$super_admin ); + $this->setup_plugin_download(); + + $request = new WP_REST_Request( 'POST', self::BASE ); + $request->set_body_params( array( 'slug' => 'link-manager' ) ); + + $response = rest_do_request( $request ); + $this->assertNotWPError( $response->as_error() ); + $this->assertEquals( 201, $response->get_status() ); + $this->assertEquals( 'Link Manager', $response->get_data()['name'] ); + } + + /** + * @ticket 50321 + */ + public function test_create_item_and_activate() { + if ( isset( get_plugins()['link-manager/link-manager.php'] ) ) { + delete_plugins( array( 'link-manager/link-manager.php' ) ); + } + + wp_set_current_user( self::$super_admin ); + $this->setup_plugin_download(); + + $request = new WP_REST_Request( 'POST', self::BASE ); + $request->set_body_params( + array( + 'slug' => 'link-manager', + 'status' => 'active', + ) + ); + + $response = rest_do_request( $request ); + $this->assertNotWPError( $response->as_error() ); + $this->assertEquals( 201, $response->get_status() ); + $this->assertEquals( 'Link Manager', $response->get_data()['name'] ); + $this->assertTrue( is_plugin_active( 'link-manager/link-manager.php' ) ); + } + + /** + * @ticket 50321 + */ + public function test_create_item_and_activate_errors_if_no_permission_to_activate_plugin() { + if ( isset( get_plugins()['link-manager/link-manager.php'] ) ) { + delete_plugins( array( 'link-manager/link-manager.php' ) ); + } + + wp_set_current_user( self::$super_admin ); + $this->setup_plugin_download(); + $this->disable_activate_permission( 'link-manager/link-manager.php' ); + + $request = new WP_REST_Request( 'POST', self::BASE ); + $request->set_body_params( + array( + 'slug' => 'link-manager', + 'status' => 'active', + ) + ); + + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_cannot_activate_plugin', $response ); + $this->assertFalse( is_plugin_active( 'link-manager/link-manager.php' ) ); + } + + /** + * @group ms-excluded + * @ticket 50321 + */ + public function test_create_item_and_network_activate_rejected_if_not_multisite() { + if ( isset( get_plugins()['link-manager/link-manager.php'] ) ) { + delete_plugins( array( 'link-manager/link-manager.php' ) ); + } + + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'POST', self::BASE ); + $request->set_body_params( + array( + 'slug' => 'link-manager', + 'status' => 'network-active', + ) + ); + + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_create_item_and_network_activate() { + if ( isset( get_plugins()['link-manager/link-manager.php'] ) ) { + delete_plugins( array( 'link-manager/link-manager.php' ) ); + } + + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'POST', self::BASE ); + $request->set_body_params( + array( + 'slug' => 'link-manager', + 'status' => 'network-active', + ) + ); + + $response = rest_do_request( $request ); + $this->assertNotWPError( $response->as_error() ); + $this->assertEquals( 201, $response->get_status() ); + $this->assertEquals( 'Link Manager', $response->get_data()['name'] ); + $this->assertTrue( is_plugin_active_for_network( 'link-manager/link-manager.php' ) ); + } + + /** + * @ticket 50321 + */ + public function test_create_item_logged_out() { + $request = new WP_REST_Request( 'POST', self::BASE ); + $request->set_body_params( array( 'slug' => 'link-manager' ) ); + + $response = rest_do_request( $request ); + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * @ticket 50321 + */ + public function test_create_item_insufficient_permissions() { + wp_set_current_user( self::$subscriber_id ); + $request = new WP_REST_Request( 'POST', self::BASE ); + $request->set_body_params( array( 'slug' => 'link-manager' ) ); + + $response = rest_do_request( $request ); + $this->assertEquals( 403, $response->get_status() ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_cannot_create_item_if_not_super_admin() { + $this->create_test_plugin(); + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'POST', self::BASE ); + $request->set_body_params( array( 'slug' => 'link-manager' ) ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_cannot_install_plugin', $response->as_error(), 403 ); + } + + /** + * @ticket 50321 + */ + public function test_create_item_wdotorg_unreachable() { + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'POST', self::BASE ); + $request->set_body_params( array( 'slug' => 'foo' ) ); + + $this->prevent_requests_to_host( 'api.wordpress.org' ); + + $this->expectException( 'PHPUnit_Framework_Error_Warning' ); + $response = rest_do_request( $request ); + $this->assertErrorResponse( 'plugins_api_failed', $response, 500 ); + } + + /** + * @ticket 50321 + */ + public function test_create_item_unknown_plugin() { + wp_set_current_user( self::$super_admin ); + + // This will hit the live API. + $request = new WP_REST_Request( 'POST', self::BASE ); + $request->set_body_params( array( 'slug' => 'alex-says-this-block-definitely-doesnt-exist' ) ); + $response = rest_do_request( $request ); + + // Is this an appropriate status? + $this->assertErrorResponse( 'plugins_api_failed', $response, 404 ); + } + + /** + * @ticket 50321 + */ + public function test_update_item() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * @ticket 50321 + */ + public function test_update_item_logged_out() { + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $response = rest_do_request( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * @ticket 50321 + */ + public function test_update_item_insufficient_permissions() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $response = rest_do_request( $request ); + + $this->assertEquals( 403, $response->get_status() ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_cannot_update_item_if_plugins_menu_not_available() { + $this->create_test_plugin(); + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_cannot_manage_plugins', $response->as_error(), 403 ); + } + + /** + * @ticket 50321 + */ + public function test_update_item_activate_plugin() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'active' ) ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( is_plugin_active( self::PLUGIN_FILE ) ); + } + + /** + * @ticket 50321 + */ + public function test_update_item_activate_plugin_fails_if_no_activate_cap() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + $this->disable_activate_permission( self::PLUGIN_FILE ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'active' ) ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_cannot_activate_plugin', $response, 403 ); + } + + /** + * @group ms-excluded + * @ticket 50321 + */ + public function test_update_item_network_activate_plugin_rejected_if_not_multisite() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'network-active' ) ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_update_item_network_activate_plugin() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'network-active' ) ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( is_plugin_active_for_network( self::PLUGIN_FILE ) ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_update_item_network_activate_plugin_that_was_active_on_single_site() { + $this->create_test_plugin(); + activate_plugin( self::PLUGIN_FILE ); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'network-active' ) ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( is_plugin_active_for_network( self::PLUGIN_FILE ) ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_update_item_activate_network_only_plugin() { + $this->create_test_plugin( true ); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'active' ) ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_network_only_plugin', $response, 400 ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_update_item_network_activate_network_only_plugin() { + $this->create_test_plugin( true ); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'network-active' ) ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( is_plugin_active_for_network( self::PLUGIN_FILE ) ); + } + + /** + * @group ms-excluded + * @ticket 50321 + */ + public function test_update_item_activate_network_only_plugin_on_non_multisite() { + $this->create_test_plugin( true ); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'active' ) ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( is_plugin_active( self::PLUGIN_FILE ) ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_update_item_activate_plugin_for_site_if_menu_item_available() { + $this->create_test_plugin(); + $this->enable_plugins_menu_item(); + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'active' ) ); + $response = rest_do_request( $request ); + + $this->assertNotWPError( $response->as_error() ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( is_plugin_active( self::PLUGIN_FILE ) ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_update_item_network_activate_plugin_for_site_if_menu_item_available() { + $this->create_test_plugin(); + $this->enable_plugins_menu_item(); + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'network-active' ) ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_cannot_manage_network_plugins', $response, 403 ); + } + + /** + * @ticket 50321 + */ + public function test_update_item_deactivate_plugin() { + $this->create_test_plugin(); + activate_plugin( self::PLUGIN_FILE ); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'inactive' ) ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( is_plugin_inactive( self::PLUGIN_FILE ) ); + } + + /** + * @ticket 50321 + */ + public function test_update_item_deactivate_plugin_fails_if_no_deactivate_cap() { + $this->create_test_plugin(); + activate_plugin( self::PLUGIN_FILE ); + wp_set_current_user( self::$super_admin ); + $this->disable_deactivate_permission( self::PLUGIN_FILE ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'inactive' ) ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_cannot_deactivate_plugin', $response, 403 ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_update_item_deactivate_network_active_plugin() { + $this->create_test_plugin(); + activate_plugin( self::PLUGIN_FILE, '', true ); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'inactive' ) ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( is_plugin_inactive( self::PLUGIN_FILE ) ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_update_item_deactivate_network_active_plugin_if_not_super_admin() { + $this->enable_plugins_menu_item(); + $this->create_test_plugin(); + activate_plugin( self::PLUGIN_FILE, '', true ); + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'PUT', self::BASE . '/' . self::PLUGIN ); + $request->set_body_params( array( 'status' => 'inactive' ) ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_cannot_manage_network_plugins', $response, 403 ); + } + + /** + * @ticket 50321 + */ + public function test_delete_item() { + $this->create_test_plugin(); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'DELETE', self::BASE . '/' . self::PLUGIN ); + $response = rest_do_request( $request ); + + $this->assertNotWPError( $response->as_error() ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['deleted'] ); + $this->assertEquals( self::PLUGIN, $response->get_data()['previous']['plugin'] ); + $this->assertFileNotExists( WP_PLUGIN_DIR . '/' . self::PLUGIN_FILE ); + } + + /** + * @ticket 50321 + */ + public function test_delete_item_logged_out() { + $request = new WP_REST_Request( 'DELETE', self::BASE . '/' . self::PLUGIN ); + $response = rest_do_request( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + /** + * @ticket 50321 + */ + public function test_delete_item_insufficient_permissions() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'DELETE', self::BASE . '/' . self::PLUGIN ); + $response = rest_do_request( $request ); + + $this->assertEquals( 403, $response->get_status() ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_cannot_delete_item_if_plugins_menu_not_available() { + wp_set_current_user( self::$admin ); + + $request = new WP_REST_Request( 'DELETE', self::BASE . '/' . self::PLUGIN ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_cannot_manage_plugins', $response->as_error(), 403 ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_cannot_delete_item_if_plugins_menu_is_available() { + wp_set_current_user( self::$admin ); + $this->enable_plugins_menu_item(); + + $request = new WP_REST_Request( 'DELETE', self::BASE . '/' . self::PLUGIN ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_cannot_manage_plugins', $response->as_error(), 403 ); + } + + /** + * @ticket 50321 + */ + public function test_delete_item_active_plugin() { + $this->create_test_plugin(); + activate_plugin( self::PLUGIN_FILE ); + wp_set_current_user( self::$super_admin ); + + $request = new WP_REST_Request( 'DELETE', self::BASE . '/' . self::PLUGIN ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_cannot_delete_active_plugin', $response ); + } + + /** + * @ticket 50321 + */ + public function test_prepare_item() { + $this->create_test_plugin(); + + $item = get_plugins()[ self::PLUGIN_FILE ]; + $item['_file'] = self::PLUGIN_FILE; + + $endpoint = new WP_REST_Plugins_Controller(); + $response = $endpoint->prepare_item_for_response( $item, new WP_REST_Request( 'GET', self::BASE . '/' . self::PLUGIN ) ); + + $this->check_get_plugin_data( $response->get_data() ); + $links = $response->get_links(); + $this->assertArrayHasKey( 'self', $links ); + $this->assertEquals( rest_url( self::BASE . '/' . self::PLUGIN ), $links['self'][0]['href'] ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_prepare_item_network_active() { + $this->create_test_plugin(); + activate_plugin( self::PLUGIN_FILE, '', true ); + + $item = get_plugins()[ self::PLUGIN_FILE ]; + $item['_file'] = self::PLUGIN_FILE; + + $endpoint = new WP_REST_Plugins_Controller(); + $response = $endpoint->prepare_item_for_response( $item, new WP_REST_Request( 'GET', self::BASE . '/' . self::PLUGIN ) ); + + $this->assertEquals( 'network-active', $response->get_data()['status'] ); + } + + /** + * @group ms-required + * @ticket 50321 + */ + public function test_prepare_item_network_only() { + $this->create_test_plugin( true ); + + $item = get_plugins()[ self::PLUGIN_FILE ]; + $item['_file'] = self::PLUGIN_FILE; + + $endpoint = new WP_REST_Plugins_Controller(); + $response = $endpoint->prepare_item_for_response( $item, new WP_REST_Request( 'GET', self::BASE . '/' . self::PLUGIN ) ); + + $this->check_get_plugin_data( $response->get_data(), true ); + } + + /** + * @ticket 50321 + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', self::BASE ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $this->assertCount( 12, $properties ); + $this->assertArrayHasKey( 'plugin', $properties ); + $this->assertArrayHasKey( 'status', $properties ); + $this->assertArrayHasKey( 'name', $properties ); + $this->assertArrayHasKey( 'plugin_uri', $properties ); + $this->assertArrayHasKey( 'description', $properties ); + $this->assertArrayHasKey( 'author', $properties ); + $this->assertArrayHasKey( 'author_uri', $properties ); + $this->assertArrayHasKey( 'version', $properties ); + $this->assertArrayHasKey( 'network_only', $properties ); + $this->assertArrayHasKey( 'requires_wp', $properties ); + $this->assertArrayHasKey( 'requires_php', $properties ); + $this->assertArrayHasKey( 'text_domain', $properties ); + } + + /** + * Checks the response data. + * + * @since 5.5.0 + * + * @param array $data Prepared plugin data. + * @param bool $network_only Whether the plugin is network only. + */ + protected function check_get_plugin_data( $data, $network_only = false ) { + $this->assertEquals( 'test-plugin/test-plugin', $data['plugin'] ); + $this->assertEquals( '1.5.4', $data['version'] ); + $this->assertEquals( 'inactive', $data['status'] ); + $this->assertEquals( 'Test Plugin', $data['name'] ); + $this->assertEquals( 'https://wordpress.org/plugins/test-plugin/', $data['plugin_uri'] ); + $this->assertEquals( 'WordPress.org', $data['author'] ); + $this->assertEquals( 'https://wordpress.org/', $data['author_uri'] ); + $this->assertEquals( "My 'Cool' Plugin", $data['description']['raw'] ); + $this->assertEquals( 'My ‘Cool’ Plugin By WordPress.org.', $data['description']['rendered'] ); + $this->assertEquals( $network_only, $data['network_only'] ); + $this->assertEquals( '5.6.0', $data['requires_php'] ); + $this->assertEquals( '5.4.0', $data['requires_wp'] ); + $this->assertEquals( 'test-plugin', $data['text_domain'] ); + } + + /** + * Sets up the plugin download to come locally instead of downloading it from .org + * + * @since 5.5.0 + */ + protected function setup_plugin_download() { + copy( DIR_TESTDATA . '/plugins/link-manager.zip', DIR_TESTDATA . '/link-manager.zip' ); + add_filter( + 'upgrader_pre_download', + function () { + return DIR_TESTDATA . '/link-manager.zip'; + } + ); + } + + /** + * Disables permission for activating a specific plugin. + * + * @since 5.5.0 + * + * @param string $plugin The plugin file to disable. + */ + protected function disable_activate_permission( $plugin ) { + add_filter( + 'map_meta_cap', + static function ( $caps, $cap, $user, $args ) use ( $plugin ) { + if ( 'activate_plugin' === $cap && $plugin === $args[0] ) { + $caps = array( 'do_not_allow' ); + } + + return $caps; + }, + 10, + 4 + ); + } + + /** + * Disables permission for deactivating a specific plugin. + * + * @since 5.5.0 + * + * @param string $plugin The plugin file to disable. + */ + protected function disable_deactivate_permission( $plugin ) { + add_filter( + 'map_meta_cap', + static function ( $caps, $cap, $user, $args ) use ( $plugin ) { + if ( 'deactivate_plugin' === $cap && $plugin === $args[0] ) { + $caps = array( 'do_not_allow' ); + } + + return $caps; + }, + 10, + 4 + ); + } + + /** + * Enables the "plugins" as an available menu item. + * + * @since 5.5.0 + */ + protected function enable_plugins_menu_item() { + $menu_perms = get_site_option( 'menu_items', array() ); + $menu_perms['plugins'] = true; + update_site_option( 'menu_items', $menu_perms ); + } + + /** + * Creates a test plugin. + * + * @since 5.5.0 + * + * @param bool $network_only Whether to make this a network only plugin. + */ + private function create_test_plugin( $network_only = false ) { + $network = $network_only ? PHP_EOL . ' * Network: true' . PHP_EOL : ''; + + $php = <<[a-zA-Z0-9_-]+)/(?P[a-zA-Z0-9_-]+)', '/wp/v2/settings', '/wp/v2/themes', + '/wp/v2/plugins', + '/wp/v2/plugins/(?P[^.\/]+(?:\/[^.\/]+)?)', + '/wp/v2/block-directory/search', ); $this->assertEquals( $expected_routes, $routes ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 851f42627f..491e35b320 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -4703,6 +4703,216 @@ mockedApiResponse.Schema = { "_links": { "self": "http://example.org/index.php?rest_route=/wp/v2/themes" } + }, + "/wp/v2/plugins": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "required": false, + "default": "view", + "enum": [ + "view", + "embed", + "edit" + ], + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string" + }, + "search": { + "required": false, + "description": "Limit results to those matching a string.", + "type": "string" + }, + "status": { + "required": false, + "description": "Limits results to plugins with the given status.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "inactive", + "active" + ] + } + } + } + }, + { + "methods": [ + "POST" + ], + "args": { + "slug": { + "required": true, + "description": "WordPress.org plugin directory slug.", + "type": "string" + }, + "status": { + "required": false, + "default": "inactive", + "enum": [ + "inactive", + "active" + ], + "description": "The plugin activation status.", + "type": "string" + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/plugins" + } + ] + } + }, + "/wp/v2/plugins/(?P[^.\\/]+(?:\\/[^.\\/]+)?)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "required": false, + "default": "view", + "enum": [ + "view", + "embed", + "edit" + ], + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string" + }, + "plugin": { + "required": false, + "type": "string" + } + } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "args": { + "context": { + "required": false, + "default": "view", + "enum": [ + "view", + "embed", + "edit" + ], + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string" + }, + "plugin": { + "required": false, + "type": "string" + }, + "status": { + "required": false, + "enum": [ + "inactive", + "active" + ], + "description": "The plugin activation status.", + "type": "string" + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": { + "context": { + "required": false, + "default": "view", + "enum": [ + "view", + "embed", + "edit" + ], + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string" + }, + "plugin": { + "required": false, + "type": "string" + } + } + } + ] + }, + "/wp/v2/block-directory/search": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "required": false, + "default": "view", + "enum": [ + "view" + ], + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string" + }, + "page": { + "required": false, + "default": 1, + "description": "Current page of the collection.", + "type": "integer" + }, + "per_page": { + "required": false, + "default": 10, + "description": "Maximum number of items to be returned in result set.", + "type": "integer" + }, + "term": { + "required": true, + "description": "Limit result set to blocks matching the search term.", + "type": "string" + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/block-directory/search" + } + ] + } } } };