From a0a7cccd6a364b566e86bdfe0b8c2dae4d6f2f8b Mon Sep 17 00:00:00 2001 From: MohammadReza Alidoosti <34165020+Mr-Alidoosti@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:29:07 +0330 Subject: [PATCH 1/2] Upgrade/Install: Integrate closed, suspended, and outdated plugins/themes API data. See #66239. --- src/js/_enqueues/wp/theme.js | 8 + src/wp-admin/css/list-tables.css | 27 + src/wp-admin/css/themes.css | 39 ++ .../includes/class-wp-plugins-list-table.php | 44 +- .../includes/class-wp-site-health.php | 296 ++++++++++ src/wp-admin/includes/plugin-install.php | 138 ++++- src/wp-admin/includes/theme-install.php | 142 +++++ src/wp-admin/includes/theme.php | 42 +- src/wp-admin/includes/update.php | 121 ++++- src/wp-admin/themes.php | 122 ++++- src/wp-includes/update.php | 59 ++ tests/phpunit/tests/plugins/closedPlugins.php | 504 ++++++++++++++++++ tests/phpunit/tests/theme/closedThemes.php | 285 ++++++++++ 13 files changed, 1796 insertions(+), 31 deletions(-) create mode 100644 tests/phpunit/tests/plugins/closedPlugins.php create mode 100644 tests/phpunit/tests/theme/closedThemes.php diff --git a/src/js/_enqueues/wp/theme.js b/src/js/_enqueues/wp/theme.js index ff5d72ad2801e..1c71990d42dfe 100644 --- a/src/js/_enqueues/wp/theme.js +++ b/src/js/_enqueues/wp/theme.js @@ -431,6 +431,10 @@ themes.view.Theme = wp.Backbone.View.extend({ // Renders active theme styles. this.activeTheme(); + this.$el.toggleClass( 'closed', !! ( this.model.get( 'closed' ) || this.model.get( 'is_suspended' ) ) ); + this.$el.toggleClass( 'outdated', !! this.model.get( 'is_outdated' ) ); + this.$el.toggleClass( 'security-issue', !! this.model.get( 'is_security' ) ); + if ( this.model.get( 'displayAuthor' ) ) { this.$el.addClass( 'display-author' ); } @@ -708,6 +712,10 @@ themes.view.Details = wp.Backbone.View.extend({ this.$el.html( this.html( data ) ); // Renders active theme styles. this.activeTheme(); + + this.$el.toggleClass( 'closed', !! ( this.model.get( 'closed' ) || this.model.get( 'is_suspended' ) ) ); + this.$el.toggleClass( 'outdated', !! this.model.get( 'is_outdated' ) ); + this.$el.toggleClass( 'security-issue', !! this.model.get( 'is_security' ) ); // Set up navigation events. this.navigation(); // Checks screenshot size. diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index f6105ad5a59fb..eb05b85baf0cb 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1349,6 +1349,33 @@ ul.cat-checklist input[name="post_category[]"]:indeterminate::before { white-space: nowrap; } +.plugins .plugin-status-badge { + display: inline-block; + font-size: 11px; + line-height: 1.4; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + margin-left: 6px; + vertical-align: middle; + white-space: nowrap; +} + +.plugins .plugin-status-badge-closed { + background-color: #d63638; + color: #fff; +} + +.plugins .plugin-status-badge-outdated { + background-color: #dba617; + color: #1d2327; +} + +[dir="rtl"] .plugins .plugin-status-badge { + margin-left: 0; + margin-right: 6px; +} + .plugins .plugin-title .dashicons, .plugins .plugin-title img.plugin-icon, .plugins .plugin-title img.updates-table-screenshot { diff --git a/src/wp-admin/css/themes.css b/src/wp-admin/css/themes.css index 5b71c223b6ce6..ff7540248cf0d 100644 --- a/src/wp-admin/css/themes.css +++ b/src/wp-admin/css/themes.css @@ -87,6 +87,45 @@ body.js .theme-browser.search-loading { background: #fff; } +.theme-browser .theme .theme-status-badge { + display: inline-block; + font-size: 11px; + line-height: 1.4; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + margin-left: 6px; + vertical-align: middle; + white-space: nowrap; +} + +.theme-browser .theme .theme-status-badge-closed { + background-color: #d63638; + color: #fff; +} + +.theme-browser .theme .theme-status-badge-outdated { + background-color: #dba617; + color: #1d2327; +} + +[dir="rtl"] .theme-browser .theme .theme-status-badge { + margin-left: 0; + margin-right: 6px; +} + +.theme-browser .theme.closed { + border-color: #d63638; +} + +.theme-browser .theme.outdated { + border-color: #dba617; +} + +.theme-browser .theme .theme-status-notice { + z-index: 10; +} + /* Activate and Customize buttons, shown on hover and focus */ .theme-browser .theme .theme-actions { -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; diff --git a/src/wp-admin/includes/class-wp-plugins-list-table.php b/src/wp-admin/includes/class-wp-plugins-list-table.php index d8945e103064e..34b5306d1c94a 100644 --- a/src/wp-admin/includes/class-wp-plugins-list-table.php +++ b/src/wp-admin/includes/class-wp-plugins-list-table.php @@ -1195,15 +1195,46 @@ public function single_row( $item ) { $plugin_name = $plugin_data['Name']; } + $is_plugin_closed = ! empty( $plugin_data['closed'] ) || 'closed' === ( $plugin_data['status'] ?? '' ) || 'disabled' === ( $plugin_data['status'] ?? '' ); + $is_plugin_security = ! empty( $plugin_data['is_security'] ) || 'security-issue' === ( $plugin_data['reason'] ?? '' ) || 'security-issue' === ( $plugin_data['closed_reason'] ?? '' ); + $is_plugin_outdated = ! empty( $plugin_data['is_outdated'] ); + + if ( ! $is_plugin_closed && ! $is_plugin_outdated ) { + $update_plugins_transient = get_site_transient( 'update_plugins' ); + if ( is_object( $update_plugins_transient ) ) { + $transient_item = null; + if ( isset( $update_plugins_transient->response[ $plugin_file ] ) ) { + $transient_item = (object) $update_plugins_transient->response[ $plugin_file ]; + } elseif ( isset( $update_plugins_transient->no_update[ $plugin_file ] ) ) { + $transient_item = (object) $update_plugins_transient->no_update[ $plugin_file ]; + } + if ( $transient_item ) { + $is_plugin_closed = ! empty( $transient_item->closed ) || 'closed' === ( $transient_item->status ?? '' ) || 'disabled' === ( $transient_item->status ?? '' ); + $is_plugin_security = ! empty( $transient_item->is_security ) || 'security-issue' === ( $transient_item->reason ?? '' ) || 'security-issue' === ( $transient_item->closed_reason ?? '' ); + $is_plugin_outdated = ! empty( $transient_item->is_outdated ); + } + } + } + if ( ! empty( $totals['upgrade'] ) && ! empty( $plugin_data['update'] ) || ! $compatible_php || - ! $compatible_wp + ! $compatible_wp || + $is_plugin_closed || + $is_plugin_outdated ) { $class .= ' update'; } + if ( $is_plugin_closed ) { + $class .= ' closed'; + } + + if ( $is_plugin_outdated ) { + $class .= ' outdated'; + } + $paused = ! $screen->in_admin( 'network' ) && is_plugin_paused( $plugin_file ); if ( $paused ) { @@ -1236,7 +1267,16 @@ public function single_row( $item ) { echo "$checkbox"; break; case 'name': - echo "$plugin_name"; + $badges = ''; + if ( $is_plugin_closed ) { + $badge_text = $is_plugin_security ? __( 'Closed (Security)' ) : __( 'Closed' ); + $badge_class = $is_plugin_security ? 'plugin-status-badge-closed plugin-status-badge-security' : 'plugin-status-badge-closed'; + $badges .= ' ' . __( 'Plugin status:' ) . ' ' . esc_html( $badge_text ) . ''; + } elseif ( $is_plugin_outdated ) { + $badges .= ' ' . __( 'Plugin status:' ) . ' ' . __( 'Outdated' ) . ''; + } + + echo "$plugin_name$badges"; echo $this->row_actions( $actions, true ); echo ''; break; diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 31e940b2076e1..6725374d3e589 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -724,6 +724,298 @@ public function get_test_theme_version() { return $result; } + /** + * Tests if any active plugins or themes are closed, suspended, outdated, or removed for security reasons. + * + * @since 7.2.0 + * + * @return array The test result. + */ + public function get_test_closed_plugins_and_themes() { + $result = array( + 'label' => __( 'Your active plugins and themes are all maintained and available in the directory' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Security' ), + 'color' => 'blue', + ), + 'description' => sprintf( + '

%s

', + __( 'Plugins and themes extend your site’s design and functionality. Keeping active plugins and themes that are supported and maintained is essential for site security and reliability.' ) + ), + 'actions' => '', + 'test' => 'closed_plugins_and_themes', + ); + + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $plugins = get_plugins(); + $active_plugins = (array) get_option( 'active_plugins', array() ); + if ( is_multisite() ) { + $network_active = array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ); + $active_plugins = array_unique( array_merge( $active_plugins, $network_active ) ); + } + + $plugin_updates = get_site_transient( 'update_plugins' ); + $theme_updates = get_site_transient( 'update_themes' ); + + $security_issues = array(); + $other_issues = array(); + + foreach ( $active_plugins as $plugin_file ) { + if ( ! isset( $plugins[ $plugin_file ] ) ) { + continue; + } + + $plugin_data = $plugins[ $plugin_file ]; + $update_data = null; + if ( isset( $plugin_updates->response[ $plugin_file ] ) ) { + $update_data = (object) $plugin_updates->response[ $plugin_file ]; + } elseif ( isset( $plugin_updates->no_update[ $plugin_file ] ) ) { + $update_data = (object) $plugin_updates->no_update[ $plugin_file ]; + } + + if ( ! $update_data ) { + continue; + } + + $is_closed = ! empty( $update_data->closed ) || 'closed' === ( $update_data->status ?? '' ) || 'disabled' === ( $update_data->status ?? '' ); + $is_security = ! empty( $update_data->is_security ) || 'security-issue' === ( $update_data->reason ?? '' ) || 'security-issue' === ( $update_data->closed_reason ?? '' ); + $is_outdated = ! empty( $update_data->is_outdated ); + + if ( ! $is_closed && ! $is_outdated ) { + continue; + } + + $deactivate_url = wp_nonce_url( + admin_url( 'plugins.php?action=deactivate&plugin=' . urlencode( $plugin_file ) ), + 'deactivate-plugin_' . $plugin_file + ); + $delete_url = wp_nonce_url( + admin_url( 'plugins.php?action=delete-selected&checked[]=' . urlencode( $plugin_file ) ), + 'bulk-plugins' + ); + + $item = array( + 'type' => 'plugin', + 'file' => $plugin_file, + 'name' => $plugin_data['Name'], + 'is_security' => $is_security, + 'is_closed' => $is_closed, + 'is_outdated' => $is_outdated, + 'closed_date' => $update_data->closed_date ?? '', + 'reason' => $update_data->reason_text ?? ( $update_data->closed_reason ?? ( $update_data->reason ?? '' ) ), + 'deactivate_url' => $deactivate_url, + 'delete_url' => $delete_url, + ); + + if ( $is_closed && $is_security ) { + $security_issues[] = $item; + } else { + $other_issues[] = $item; + } + } + + $current_theme = wp_get_theme(); + $themes_to_check = array( $current_theme->get_stylesheet() => $current_theme ); + if ( $current_theme->parent() ) { + $parent_theme = $current_theme->parent(); + $themes_to_check[ $parent_theme->get_stylesheet() ] = $parent_theme; + } + + foreach ( $themes_to_check as $theme_slug => $theme_obj ) { + $update_data = null; + if ( isset( $theme_updates->response[ $theme_slug ] ) ) { + $update_data = (object) $theme_updates->response[ $theme_slug ]; + } elseif ( isset( $theme_updates->no_update[ $theme_slug ] ) ) { + $update_data = (object) $theme_updates->no_update[ $theme_slug ]; + } + + if ( ! $update_data ) { + continue; + } + + $is_closed = ! empty( $update_data->closed ) || ! empty( $update_data->is_closed ) || 'suspend' === ( $update_data->status ?? '' ); + $is_security = ! empty( $update_data->is_security ) || 'security-issue' === ( $update_data->reason ?? '' ) || 'security-issue' === ( $update_data->closed_reason ?? '' ); + $is_outdated = ! empty( $update_data->is_outdated ); + + if ( ! $is_closed && ! $is_outdated ) { + continue; + } + + $item = array( + 'type' => 'theme', + 'slug' => $theme_slug, + 'name' => $theme_obj->display( 'Name' ), + 'is_security' => $is_security, + 'is_closed' => $is_closed, + 'is_outdated' => $is_outdated, + 'closed_date' => $update_data->closed_date ?? '', + 'reason' => $update_data->reason_text ?? ( $update_data->reason ?? '' ), + 'themes_url' => admin_url( 'themes.php' ), + ); + + if ( $is_closed && $is_security ) { + $security_issues[] = $item; + } else { + $other_issues[] = $item; + } + } + + if ( ! empty( $security_issues ) ) { + $result['status'] = 'critical'; + $result['badge'] = array( + 'label' => __( 'Security' ), + 'color' => 'red', + ); + $result['label'] = __( 'You have plugins/themes installed that were removed from the WordPress directory due to security issues.' ); + + $list_html = ''; + + $result['description'] = sprintf( + '

%s

%s', + __( 'The following items are active on your site but have been removed from the WordPress directory due to security vulnerabilities. They should be deactivated and deleted immediately:' ), + $list_html + ); + + $result['actions'] = sprintf( + '

%s | %s

', + esc_url( admin_url( 'plugins.php' ) ), + __( 'Manage plugins' ), + esc_url( admin_url( 'themes.php' ) ), + __( 'Manage themes' ) + ); + } elseif ( ! empty( $other_issues ) ) { + $result['status'] = 'recommended'; + $result['badge'] = array( + 'label' => __( 'Security' ), + 'color' => 'orange', + ); + $result['label'] = __( 'Some installed plugins or themes are no longer maintained or available in the directory.' ); + + $list_html = ''; + + $result['description'] = sprintf( + '

%s

%s', + __( 'The following items are no longer maintained or have been closed in the WordPress directory. Consider deactivating and removing them in favor of supported alternatives:' ), + $list_html + ); + + $result['actions'] = sprintf( + '

%s | %s

', + esc_url( admin_url( 'plugins.php' ) ), + __( 'Manage plugins' ), + esc_url( admin_url( 'themes.php' ) ), + __( 'Manage themes' ) + ); + } + + return $result; + } + + /** + * Tests if any active plugins or themes are closed, suspended, outdated, or removed for security reasons. + * + * Alias for get_test_closed_plugins_and_themes(). + * + * @since 7.2.0 + * + * @return array The test result. + */ + public function test_closed_plugins_and_themes() { + return $this->get_test_closed_plugins_and_themes(); + } + /** * Tests if the supplied PHP version is supported. * @@ -2862,6 +3154,10 @@ public static function get_tests() { 'label' => __( 'Theme Versions' ), 'test' => 'theme_version', ), + 'closed_plugins_and_themes' => array( + 'label' => __( 'Closed and outdated plugins and themes' ), + 'test' => 'closed_plugins_and_themes', + ), 'php_version' => array( 'label' => __( 'PHP Version' ), 'test' => 'php_version', diff --git a/src/wp-admin/includes/plugin-install.php b/src/wp-admin/includes/plugin-install.php index b37b956d3455c..fcdcc94517355 100644 --- a/src/wp-admin/includes/plugin-install.php +++ b/src/wp-admin/includes/plugin-install.php @@ -210,7 +210,7 @@ function plugins_api( $action, $args = array() ) { ); } - if ( isset( $res->error ) ) { + if ( isset( $res->error ) && 'closed' !== $res->error ) { $res = new WP_Error( 'plugins_api_failed', $res->error ); } } @@ -530,6 +530,142 @@ function install_plugin_information() { ) ); + $is_closed = false; + if ( is_object( $api ) && ( ( isset( $api->error ) && 'closed' === $api->error ) || ! empty( $api->closed ) ) ) { + $is_closed = true; + } elseif ( is_wp_error( $api ) && 'closed' === $api->get_error_code() ) { + $is_closed = true; + $error_data = $api->get_error_data(); + if ( is_object( $error_data ) || is_array( $error_data ) ) { + $api = (object) $error_data; + } else { + $api = (object) array( + 'error' => 'closed', + 'name' => sanitize_text_field( wp_unslash( $_REQUEST['plugin'] ) ), + 'slug' => sanitize_text_field( wp_unslash( $_REQUEST['plugin'] ) ), + 'description' => $api->get_error_message(), + ); + } + } + + if ( $is_closed ) { + iframe_header( __( 'Plugin Installation' ) ); + + $plugins_allowedtags = array( + 'a' => array( + 'href' => array(), + 'title' => array(), + 'target' => array(), + ), + 'abbr' => array( 'title' => array() ), + 'acronym' => array( 'title' => array() ), + 'code' => array(), + 'pre' => array(), + 'em' => array(), + 'strong' => array(), + 'div' => array( 'class' => array() ), + 'span' => array( 'class' => array() ), + 'p' => array(), + 'br' => array(), + 'ul' => array(), + 'ol' => array(), + 'li' => array(), + 'h1' => array(), + 'h2' => array(), + 'h3' => array(), + 'h4' => array(), + 'h5' => array(), + 'h6' => array(), + ); + + $plugin_name = ! empty( $api->name ) ? wp_kses( $api->name, $plugins_allowedtags ) : sanitize_text_field( wp_unslash( $_REQUEST['plugin'] ) ); + $is_security = ! empty( $api->is_security ) || 'security-issue' === ( $api->reason ?? '' ) || 'security-issue' === ( $api->closed_reason ?? '' ); + $closed_date = ''; + if ( ! empty( $api->closed_date ) ) { + $closed_timestamp = strtotime( $api->closed_date ); + $closed_date = $closed_timestamp ? wp_date( get_option( 'date_format' ), $closed_timestamp ) : $api->closed_date; + } + $reason = $api->reason_text ?? ( $api->closed_reason ?? ( $api->reason ?? '' ) ); + $description = ! empty( $api->description ) ? wp_kses( $api->description, $plugins_allowedtags ) : ''; + + echo '
'; + echo '

' . esc_html( $plugin_name ) . '

'; + + if ( $is_security ) { + if ( $closed_date ) { + /* translators: %s: Plugin closure date. */ + $message = sprintf( __( 'Warning: This plugin was closed on %s due to a security issue and is no longer available for download. It should be uninstalled or replaced immediately.' ), esc_html( $closed_date ) ); + } else { + $message = __( 'Warning: This plugin was closed due to a security issue and is no longer available for download. It should be uninstalled or replaced immediately.' ); + } + wp_admin_notice( + $message, + array( + 'type' => 'error', + 'additional_classes' => array( 'notice-alt' ), + 'paragraph_wrap' => true, + ) + ); + } else { + if ( $closed_date && $reason ) { + /* translators: 1: Plugin closure date, 2: Plugin closure reason. */ + $message = sprintf( __( 'Notice: This plugin was closed on %1$s (%2$s) and is no longer available for download.' ), esc_html( $closed_date ), esc_html( $reason ) ); + } elseif ( $closed_date ) { + /* translators: %s: Plugin closure date. */ + $message = sprintf( __( 'Notice: This plugin was closed on %s and is no longer available for download.' ), esc_html( $closed_date ) ); + } elseif ( $reason ) { + /* translators: %s: Plugin closure reason. */ + $message = sprintf( __( 'Notice: This plugin was closed (%s) and is no longer available for download.' ), esc_html( $reason ) ); + } else { + $message = __( 'Notice: This plugin was closed and is no longer available for download.' ); + } + wp_admin_notice( + $message, + array( + 'type' => 'warning', + 'additional_classes' => array( 'notice-alt' ), + 'paragraph_wrap' => true, + ) + ); + } + + if ( ! empty( $api->is_outdated ) ) { + $outdated_msg = ! empty( $api->outdated_notice ) ? $api->outdated_notice : __( 'This plugin has not been tested with the latest 3 major releases of WordPress and may no longer be maintained.' ); + wp_admin_notice( + $outdated_msg, + array( + 'type' => 'warning', + 'additional_classes' => array( 'notice-alt' ), + 'paragraph_wrap' => true, + ) + ); + } + + if ( $description ) { + echo '
' . $description . '
'; + } + + echo ''; + + echo '
'; + + iframe_footer(); + exit; + } + if ( is_wp_error( $api ) ) { wp_die( $api ); } diff --git a/src/wp-admin/includes/theme-install.php b/src/wp-admin/includes/theme-install.php index 1eab0916563a9..0ef27781aeafe 100644 --- a/src/wp-admin/includes/theme-install.php +++ b/src/wp-admin/includes/theme-install.php @@ -259,6 +259,148 @@ function install_theme_information() { $theme = themes_api( 'theme_information', array( 'slug' => wp_unslash( $_REQUEST['theme'] ) ) ); + $is_closed = false; + if ( is_object( $theme ) && ( + ( isset( $theme->error ) && 'closed' === $theme->error ) + || ! empty( $theme->closed ) + || ! empty( $theme->is_closed ) + || ! empty( $theme->is_suspended ) + || ( isset( $theme->status ) && in_array( $theme->status, array( 'closed', 'suspend', 'disabled' ), true ) ) + ) ) { + $is_closed = true; + } elseif ( is_wp_error( $theme ) && 'closed' === $theme->get_error_code() ) { + $is_closed = true; + $error_data = $theme->get_error_data(); + if ( is_array( $error_data ) || is_object( $error_data ) ) { + $theme = (object) $error_data; + } else { + $theme = (object) array( + 'error' => 'closed', + 'name' => sanitize_text_field( wp_unslash( $_REQUEST['theme'] ) ), + 'slug' => sanitize_text_field( wp_unslash( $_REQUEST['theme'] ) ), + 'description' => $theme->get_error_message(), + ); + } + } + + if ( $is_closed ) { + iframe_header( __( 'Theme Installation' ) ); + + $themes_allowedtags = array( + 'a' => array( + 'href' => array(), + 'title' => array(), + 'target' => array(), + ), + 'abbr' => array( 'title' => array() ), + 'acronym' => array( 'title' => array() ), + 'code' => array(), + 'pre' => array(), + 'em' => array(), + 'strong' => array(), + 'div' => array( 'class' => array() ), + 'span' => array( 'class' => array() ), + 'p' => array(), + 'br' => array(), + 'ul' => array(), + 'ol' => array(), + 'li' => array(), + 'h1' => array(), + 'h2' => array(), + 'h3' => array(), + 'h4' => array(), + 'h5' => array(), + 'h6' => array(), + ); + + $theme_name = ! empty( $theme->name ) ? wp_kses( $theme->name, $themes_allowedtags ) : sanitize_text_field( wp_unslash( $_REQUEST['theme'] ) ); + $is_security = ! empty( $theme->is_security ) || 'security-issue' === ( $theme->reason ?? '' ) || 'security-issue' === ( $theme->closed_reason ?? '' ); + $closed_date = ''; + if ( ! empty( $theme->closed_date ) ) { + $closed_timestamp = strtotime( $theme->closed_date ); + $closed_date = $closed_timestamp ? wp_date( get_option( 'date_format' ), $closed_timestamp ) : $theme->closed_date; + } + $reason = $theme->reason_text ?? ( $theme->closed_reason ?? ( $theme->reason ?? '' ) ); + $description = ! empty( $theme->description ) ? wp_kses( $theme->description, $themes_allowedtags ) : ''; + + echo '
'; + echo '

' . esc_html( $theme_name ) . '

'; + + if ( $is_security ) { + if ( $closed_date ) { + /* translators: %s: Theme closure date. */ + $message = sprintf( __( 'Warning: This theme was closed on %s due to a security issue and is no longer available for download. It should be uninstalled or replaced immediately.' ), esc_html( $closed_date ) ); + } else { + $message = __( 'Warning: This theme was closed due to a security issue and is no longer available for download. It should be uninstalled or replaced immediately.' ); + } + wp_admin_notice( + $message, + array( + 'type' => 'error', + 'additional_classes' => array( 'notice-alt' ), + 'paragraph_wrap' => true, + ) + ); + } else { + if ( $closed_date && $reason ) { + /* translators: 1: Theme closure date, 2: Theme closure reason. */ + $message = sprintf( __( 'Notice: This theme was closed on %1$s (%2$s) and is no longer available for download.' ), esc_html( $closed_date ), esc_html( $reason ) ); + } elseif ( $closed_date ) { + /* translators: %s: Theme closure date. */ + $message = sprintf( __( 'Notice: This theme was closed on %s and is no longer available for download.' ), esc_html( $closed_date ) ); + } elseif ( $reason ) { + /* translators: %s: Theme closure reason. */ + $message = sprintf( __( 'Notice: This theme was closed (%s) and is no longer available for download.' ), esc_html( $reason ) ); + } else { + $message = __( 'Notice: This theme was closed and is no longer available for download.' ); + } + wp_admin_notice( + $message, + array( + 'type' => 'warning', + 'additional_classes' => array( 'notice-alt' ), + 'paragraph_wrap' => true, + ) + ); + } + + if ( ! empty( $theme->is_outdated ) ) { + $outdated_msg = ! empty( $theme->outdated_notice ) ? $theme->outdated_notice : __( 'This theme has not been updated in over 2 years and may no longer be maintained.' ); + wp_admin_notice( + $outdated_msg, + array( + 'type' => 'warning', + 'additional_classes' => array( 'notice-alt' ), + 'paragraph_wrap' => true, + ) + ); + } + + if ( $description ) { + echo '
' . $description . '
'; + } + + echo ''; + + echo '
'; + + iframe_footer(); + exit; + } + if ( is_wp_error( $theme ) ) { wp_die( $theme ); } diff --git a/src/wp-admin/includes/theme.php b/src/wp-admin/includes/theme.php index 5ee488536ede2..6c85e0be71394 100644 --- a/src/wp-admin/includes/theme.php +++ b/src/wp-admin/includes/theme.php @@ -601,7 +601,7 @@ function themes_api( $action, $args = array() ) { ); } - if ( isset( $res->error ) ) { + if ( isset( $res->error ) && 'closed' !== $res->error ) { $res = new WP_Error( 'themes_api_failed', $res->error ); } } @@ -675,16 +675,14 @@ function wp_prepare_themes_for_js( $themes = null ) { } } - $updates = array(); - $no_updates = array(); - if ( ! is_multisite() && current_user_can( 'update_themes' ) ) { - $updates_transient = get_site_transient( 'update_themes' ); - if ( isset( $updates_transient->response ) ) { - $updates = $updates_transient->response; - } - if ( isset( $updates_transient->no_update ) ) { - $no_updates = $updates_transient->no_update; - } + $updates = array(); + $no_updates = array(); + $updates_transient = get_site_transient( 'update_themes' ); + if ( isset( $updates_transient->response ) && is_array( $updates_transient->response ) ) { + $updates = $updates_transient->response; + } + if ( isset( $updates_transient->no_update ) && is_array( $updates_transient->no_update ) ) { + $no_updates = $updates_transient->no_update; } WP_Theme::sort_by_name( $themes ); @@ -758,6 +756,19 @@ function wp_prepare_themes_for_js( $themes = null ) { $auto_update_forced = wp_is_auto_update_forced_for_item( 'theme', null, $auto_update_filter_payload ); + $theme_status_data = $updates[ $slug ] ?? ( $no_updates[ $slug ] ?? array() ); + if ( is_object( $theme_status_data ) ) { + $theme_status_data = (array) $theme_status_data; + } + + $is_theme_closed = ! empty( $theme_status_data['closed'] ) || ! empty( $theme_status_data['is_closed'] ) || 'suspend' === ( $theme_status_data['status'] ?? '' ); + $is_theme_suspended = ! empty( $theme_status_data['is_suspended'] ) || 'suspend' === ( $theme_status_data['status'] ?? '' ); + $is_theme_security = ! empty( $theme_status_data['is_security'] ) || 'security-issue' === ( $theme_status_data['reason'] ?? '' ) || 'security-issue' === ( $theme_status_data['closed_reason'] ?? '' ); + $is_theme_outdated = ! empty( $theme_status_data['is_outdated'] ); + $theme_closed_date = $theme_status_data['closed_date'] ?? ''; + $theme_reason = $theme_status_data['reason_text'] ?? ( $theme_status_data['reason'] ?? '' ); + $theme_outdated_msg = $theme_status_data['outdated_notice'] ?? ''; + $prepared_themes[ $slug ] = array( 'id' => $slug, 'name' => $theme->display( 'Name' ), @@ -775,9 +786,16 @@ function wp_prepare_themes_for_js( $themes = null ) { ), 'parent' => $parent, 'active' => $slug === $current_theme, - 'hasUpdate' => isset( $updates[ $slug ] ), + 'hasUpdate' => ! empty( $updates[ $slug ] ) && current_user_can( 'update_themes' ), 'hasPackage' => isset( $updates[ $slug ] ) && ! empty( $updates[ $slug ]['package'] ), 'update' => get_theme_update_available( $theme ), + 'closed' => $is_theme_closed, + 'is_suspended' => $is_theme_suspended, + 'is_security' => $is_theme_security, + 'is_outdated' => $is_theme_outdated, + 'closedDate' => $theme_closed_date, + 'closedReason' => $theme_reason, + 'outdatedNotice' => $theme_outdated_msg, 'autoupdate' => array( 'enabled' => $auto_update || $auto_update_forced, 'supported' => $auto_update_supported, diff --git a/src/wp-admin/includes/update.php b/src/wp-admin/includes/update.php index b0e998264fe06..056379b7af7aa 100644 --- a/src/wp-admin/includes/update.php +++ b/src/wp-admin/includes/update.php @@ -422,19 +422,36 @@ function get_plugin_updates() { * @since 2.9.0 */ function wp_plugin_update_rows() { - if ( ! current_user_can( 'update_plugins' ) ) { + if ( ! current_user_can( 'update_plugins' ) && ! current_user_can( 'activate_plugins' ) ) { return; } $plugins = get_site_transient( 'update_plugins' ); + if ( ! is_object( $plugins ) ) { + return; + } + + $plugin_files = array(); + if ( isset( $plugins->response ) && is_array( $plugins->response ) ) { - $plugins = array_keys( $plugins->response ); + $plugin_files = array_keys( $plugins->response ); + } - foreach ( $plugins as $plugin_file ) { - add_action( "after_plugin_row_{$plugin_file}", 'wp_plugin_update_row', 10, 2 ); + if ( isset( $plugins->no_update ) && is_array( $plugins->no_update ) ) { + foreach ( $plugins->no_update as $plugin_file => $plugin_item ) { + $item = (object) $plugin_item; + if ( ! empty( $item->closed ) || ! empty( $item->is_outdated ) || ! empty( $item->is_security ) || 'closed' === ( $item->status ?? '' ) || 'disabled' === ( $item->status ?? '' ) ) { + $plugin_files[] = $plugin_file; + } } } + + $plugin_files = array_unique( $plugin_files ); + + foreach ( $plugin_files as $plugin_file ) { + add_action( "after_plugin_row_{$plugin_file}", 'wp_plugin_update_row', 10, 2 ); + } } /** @@ -449,11 +466,28 @@ function wp_plugin_update_rows() { function wp_plugin_update_row( $file, $plugin_data ) { $current = get_site_transient( 'update_plugins' ); - if ( ! isset( $current->response[ $file ] ) ) { + if ( ! is_object( $current ) ) { + return false; + } + + $response = null; + if ( isset( $current->response[ $file ] ) ) { + $response = (object) $current->response[ $file ]; + } elseif ( isset( $current->no_update[ $file ] ) ) { + $response = (object) $current->no_update[ $file ]; + } + + if ( ! $response ) { return false; } - $response = $current->response[ $file ]; + $is_closed = ! empty( $response->closed ) || 'closed' === ( $response->status ?? '' ) || 'disabled' === ( $response->status ?? '' ); + $is_security = ! empty( $response->is_security ) || 'security-issue' === ( $response->reason ?? '' ) || 'security-issue' === ( $response->closed_reason ?? '' ); + $is_outdated = ! empty( $response->is_outdated ); + + if ( ! isset( $current->response[ $file ] ) && ! $is_closed && ! $is_outdated ) { + return false; + } $plugins_allowedtags = array( 'a' => array( @@ -468,7 +502,7 @@ function wp_plugin_update_row( $file, $plugin_data ) { ); $plugin_name = wp_kses( $plugin_data['Name'], $plugins_allowedtags ); - $plugin_slug = $response->slug ?? $response->id; + $plugin_slug = $response->slug ?? ( $response->id ?? sanitize_title( $plugin_data['Name'] ) ); if ( isset( $response->slug ) ) { $details_url = self_admin_url( 'plugin-install.php?tab=plugin-information&plugin=' . $plugin_slug . '§ion=changelog' ); @@ -506,19 +540,77 @@ function wp_plugin_update_row( $file, $plugin_data ) { $compatible_php = is_php_version_compatible( $requires_php ); $notice_type = $compatible_php ? 'notice-warning' : 'notice-error'; + if ( $is_closed ) { + $notice_type = $is_security ? 'notice-error' : 'notice-warning'; + } elseif ( $is_outdated && ! isset( $current->response[ $file ] ) ) { + $notice_type = 'notice-warning'; + } + + $role_attr = ( $is_closed && $is_security ) || ! $compatible_php ? ' role="alert"' : ' role="status"'; + printf( '' . '' . - '

', + '

', $active_class, esc_attr( $plugin_slug . '-update' ), esc_attr( $plugin_slug ), esc_attr( $file ), esc_attr( $wp_list_table->get_column_count() ), - $notice_type + $notice_type, + $role_attr ); - if ( ! current_user_can( 'update_plugins' ) ) { + if ( $is_closed ) { + $closed_date = ''; + if ( ! empty( $response->closed_date ) ) { + $closed_timestamp = strtotime( $response->closed_date ); + $closed_date = $closed_timestamp ? wp_date( get_option( 'date_format' ), $closed_timestamp ) : $response->closed_date; + } + + $reason = $response->reason_text ?? ( $response->closed_reason ?? ( $response->reason ?? '' ) ); + + if ( $is_security ) { + if ( $closed_date ) { + printf( + /* translators: %s: Plugin closure date. */ + __( 'Warning: This plugin was closed on %s due to a security issue and is no longer available for download. It should be uninstalled or replaced immediately.' ), + esc_html( $closed_date ) + ); + } else { + _e( 'Warning: This plugin was closed due to a security issue and is no longer available for download. It should be uninstalled or replaced immediately.' ); + } + } else { + if ( $closed_date && $reason ) { + printf( + /* translators: 1: Plugin closure date, 2: Plugin closure reason. */ + __( 'Notice: This plugin was closed on %1$s (%2$s) and is no longer available for download.' ), + esc_html( $closed_date ), + esc_html( $reason ) + ); + } elseif ( $closed_date ) { + printf( + /* translators: %s: Plugin closure date. */ + __( 'Notice: This plugin was closed on %s and is no longer available for download.' ), + esc_html( $closed_date ) + ); + } elseif ( $reason ) { + printf( + /* translators: %s: Plugin closure reason. */ + __( 'Notice: This plugin was closed (%s) and is no longer available for download.' ), + esc_html( $reason ) + ); + } else { + _e( 'Notice: This plugin was closed and is no longer available for download.' ); + } + } + } elseif ( $is_outdated && ! isset( $current->response[ $file ] ) ) { + if ( ! empty( $response->outdated_notice ) ) { + echo esc_html( $response->outdated_notice ); + } else { + _e( 'This plugin has not been tested with the latest 3 major releases of WordPress and may no longer be maintained.' ); + } + } elseif ( ! current_user_can( 'update_plugins' ) ) { printf( /* translators: 1: Plugin name, 2: Details URL, 3: Additional link attributes, 4: Version number. */ __( 'There is a new version of %1$s available. View version %4$s details.' ), @@ -582,6 +674,15 @@ function wp_plugin_update_row( $file, $plugin_data ) { } } + if ( ! $is_closed && $is_outdated && isset( $current->response[ $file ] ) ) { + echo '
'; + if ( ! empty( $response->outdated_notice ) ) { + echo esc_html( $response->outdated_notice ); + } else { + _e( 'This plugin has not been tested with the latest 3 major releases of WordPress and may no longer be maintained.' ); + } + } + /** * Fires at the end of the update message container in each * row of the plugins list table. diff --git a/src/wp-admin/themes.php b/src/wp-admin/themes.php index ac2cd4a9824cb..c400971fa304e 100644 --- a/src/wp-admin/themes.php +++ b/src/wp-admin/themes.php @@ -444,7 +444,37 @@

- + + 'error', + 'additional_classes' => array( 'notice-alt', 'inline', 'theme-status-notice' ), + ) + ); + } else { + wp_admin_notice( + __( 'Suspended / Closed: This theme has been removed from the WordPress Theme Directory.' ), + array( + 'type' => 'warning', + 'additional_classes' => array( 'notice-alt', 'inline', 'theme-status-notice' ), + ) + ); + } + ?> + + 'warning', + 'additional_classes' => array( 'notice-alt', 'inline', 'theme-status-notice' ), + ) + ); + ?> +
+ ' . __( 'Theme status:' ) . ' ' . esc_html( $badge_label ) . ''; + } elseif ( ! empty( $theme['is_outdated'] ) ) { + $theme_badge = ' ' . __( 'Theme status:' ) . ' ' . __( 'Outdated' ) . ''; + } + ?>

- +

-

+

@@ -842,7 +881,21 @@ function wp_theme_auto_update_setting_template() {
<# } #> - <# if ( data.hasUpdate ) { #> + <# if ( data.closed || data.is_suspended ) { #> + <# if ( data.is_security ) { #> + + <# } else { #> +

+ +

+ <# } #> + <# } else if ( data.is_outdated ) { #> +

+ +

+ <# } else if ( data.hasUpdate ) { #> <# if ( data.updateResponse.compatibleWP && data.updateResponse.compatiblePHP ) { #>

<# if ( data.hasPackage ) { #> @@ -989,12 +1042,18 @@ function wp_theme_auto_update_setting_template() {

+ <# var themeBadge = ''; #> + <# if ( data.closed || data.is_suspended ) { #> + <# themeBadge = ' ' + ( data.is_security ? '' : '' ) + ''; #> + <# } else if ( data.is_outdated ) { #> + <# themeBadge = ' '; #> + <# } #> <# if ( data.active ) { #>

- {{{ data.name }}} + {{{ data.name }}}{{{ themeBadge }}}

<# } else { #> -

{{{ data.name }}}

+

{{{ data.name }}}{{{ themeBadge }}}

<# } #>
@@ -1154,6 +1213,57 @@ function wp_theme_auto_update_setting_template() {

<# } #> + <# if ( data.closed || data.is_suspended ) { #> + <# if ( data.is_security ) { #> + + <# } else { #> +
+

+

+ <# if ( data.closedDate && data.closedReason ) { #> + + <# } else { #> + + <# } #> +

+
+ <# } #> + <# } #> + + <# if ( data.is_outdated ) { #> +
+

+ <# if ( data.outdatedNotice ) { #> + {{{ data.outdatedNotice }}} + <# } else { #> + + <# } #> +

+
+ <# } #> + <# if ( data.hasUpdate ) { #> <# if ( data.updateResponse.compatibleWP && data.updateResponse.compatiblePHP ) { #>
diff --git a/src/wp-includes/update.php b/src/wp-includes/update.php index 9f4257ab03da6..710b18a691b29 100644 --- a/src/wp-includes/update.php +++ b/src/wp-includes/update.php @@ -503,6 +503,14 @@ function wp_update_plugins( $extra_stats = array() ) { $updates->response = $response['plugins']; $updates->translations = $response['translations']; $updates->no_update = $response['no_update']; + + if ( isset( $response['closed'] ) && is_array( $response['closed'] ) ) { + foreach ( $response['closed'] as $closed_plugin => $closed_data ) { + if ( ! isset( $updates->response[ $closed_plugin ] ) && ! isset( $updates->no_update[ $closed_plugin ] ) ) { + $updates->no_update[ $closed_plugin ] = (object) $closed_data; + } + } + } } // Support updates for any plugins using the `Update URI` header field. @@ -600,6 +608,16 @@ function wp_update_plugins( $extra_stats = array() ) { unset( $item->translations, $item->compatibility ); + if ( isset( $item->closed ) ) { + $item->closed = (bool) $item->closed; + } + if ( isset( $item->is_outdated ) ) { + $item->is_outdated = (bool) $item->is_outdated; + } + if ( isset( $item->is_security ) ) { + $item->is_security = (bool) $item->is_security; + } + return $item; }; @@ -784,6 +802,14 @@ function wp_update_themes( $extra_stats = array() ) { $new_update->response = $response['themes']; $new_update->no_update = $response['no_update']; $new_update->translations = $response['translations']; + + if ( isset( $response['closed'] ) && is_array( $response['closed'] ) ) { + foreach ( $response['closed'] as $closed_theme => $closed_data ) { + if ( ! isset( $new_update->response[ $closed_theme ] ) && ! isset( $new_update->no_update[ $closed_theme ] ) ) { + $new_update->no_update[ $closed_theme ] = (array) $closed_data; + } + } + } } // Support updates for any themes using the `Update URI` header field. @@ -872,6 +898,39 @@ function wp_update_themes( $extra_stats = array() ) { } } + $sanitize_theme_update_payload = static function ( &$item ) { + if ( is_object( $item ) ) { + $item = (array) $item; + } + + if ( is_array( $item ) ) { + if ( isset( $item['closed'] ) ) { + $item['closed'] = (bool) $item['closed']; + } + if ( isset( $item['is_closed'] ) ) { + $item['is_closed'] = (bool) $item['is_closed']; + } + if ( isset( $item['is_suspended'] ) ) { + $item['is_suspended'] = (bool) $item['is_suspended']; + } + if ( isset( $item['is_outdated'] ) ) { + $item['is_outdated'] = (bool) $item['is_outdated']; + } + if ( isset( $item['is_security'] ) ) { + $item['is_security'] = (bool) $item['is_security']; + } + } + + return $item; + }; + + if ( is_array( $new_update->response ) ) { + array_walk( $new_update->response, $sanitize_theme_update_payload ); + } + if ( is_array( $new_update->no_update ) ) { + array_walk( $new_update->no_update, $sanitize_theme_update_payload ); + } + set_site_transient( 'update_themes', $new_update ); } diff --git a/tests/phpunit/tests/plugins/closedPlugins.php b/tests/phpunit/tests/plugins/closedPlugins.php new file mode 100644 index 0000000000000..b98b3c6677ce9 --- /dev/null +++ b/tests/phpunit/tests/plugins/closedPlugins.php @@ -0,0 +1,504 @@ + array( + 'Name' => 'Security Plugin', + 'Version' => '1.0.0', + 'PluginURI' => 'https://wordpress.org/plugins/sec-plugin/', + 'Author' => 'Tester', + 'TextDomain' => 'sec-plugin', + 'UpdateURI' => '', + ), + 'outdated/outdated.php' => array( + 'Name' => 'Outdated Plugin', + 'Version' => '2.0.0', + 'PluginURI' => 'https://wordpress.org/plugins/outdated/', + 'Author' => 'Tester', + 'TextDomain' => 'outdated', + 'UpdateURI' => '', + ), + 'general-closed/general.php' => array( + 'Name' => 'General Closed Plugin', + 'Version' => '1.5.0', + 'PluginURI' => 'https://wordpress.org/plugins/general-closed/', + 'Author' => 'Tester', + 'TextDomain' => 'general-closed', + 'UpdateURI' => '', + ), + ); + + add_filter( + 'all_plugins', + static function () use ( $test_plugins ) { + return $test_plugins; + } + ); + + add_filter( + 'pre_http_request', + static function ( $response, $parsed_args, $url ) { + if ( false === strpos( $url, 'api.wordpress.org/plugins/update-check' ) ) { + return $response; + } + + $mock_body = array( + 'plugins' => array( + 'sec-plugin/sec-plugin.php' => (object) array( + 'id' => 'w.org/plugins/sec-plugin', + 'slug' => 'sec-plugin', + 'plugin' => 'sec-plugin/sec-plugin.php', + 'new_version' => '1.1.0', + 'url' => 'https://wordpress.org/plugins/sec-plugin/', + 'package' => '', + 'closed' => true, + 'closed_date' => '2025-01-15', + 'closed_reason' => 'security-issue', + 'reason' => 'security-issue', + 'reason_text' => 'Security Issue', + 'is_security' => true, + 'is_outdated' => false, + ), + ), + 'translations' => array(), + 'no_update' => array( + 'outdated/outdated.php' => (object) array( + 'id' => 'w.org/plugins/outdated', + 'slug' => 'outdated', + 'plugin' => 'outdated/outdated.php', + 'new_version' => '2.0.0', + 'url' => 'https://wordpress.org/plugins/outdated/', + 'is_outdated' => true, + 'outdated_notice' => 'This plugin has not been tested with the latest 3 major releases.', + 'closed' => false, + ), + ), + 'closed' => array( + 'general-closed/general.php' => (object) array( + 'id' => 'w.org/plugins/general-closed', + 'slug' => 'general-closed', + 'plugin' => 'general-closed/general.php', + 'new_version' => '1.5.0', + 'url' => 'https://wordpress.org/plugins/general-closed/', + 'closed' => true, + 'closed_date' => '2024-06-20', + 'closed_reason' => 'author-request', + 'reason' => 'author-request', + 'reason_text' => 'Author Request', + 'is_security' => false, + 'is_outdated' => false, + ), + ), + ); + + return array( + 'headers' => array(), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'body' => wp_json_encode( $mock_body ), + 'cookies' => array(), + 'filename' => null, + ); + }, + 10, + 3 + ); + + delete_site_transient( 'update_plugins' ); + wp_update_plugins( array( 'core' => 'test' ) ); + + $transient = get_site_transient( 'update_plugins' ); + $this->assertIsObject( $transient, 'Transient should be an object.' ); + $this->assertArrayHasKey( 'sec-plugin/sec-plugin.php', $transient->response ); + + $sec_item = $transient->response['sec-plugin/sec-plugin.php']; + $this->assertTrue( $sec_item->closed ); + $this->assertTrue( $sec_item->is_security ); + $this->assertSame( '2025-01-15', $sec_item->closed_date ); + $this->assertSame( 'security-issue', $sec_item->closed_reason ); + + $this->assertArrayHasKey( 'outdated/outdated.php', $transient->no_update ); + $outdated_item = $transient->no_update['outdated/outdated.php']; + $this->assertTrue( $outdated_item->is_outdated ); + $this->assertSame( 'This plugin has not been tested with the latest 3 major releases.', $outdated_item->outdated_notice ); + + $this->assertArrayHasKey( 'general-closed/general.php', $transient->no_update ); + $closed_item = $transient->no_update['general-closed/general.php']; + $this->assertTrue( $closed_item->closed ); + $this->assertFalse( $closed_item->is_security ); + $this->assertSame( '2024-06-20', $closed_item->closed_date ); + $this->assertSame( 'author-request', $closed_item->closed_reason ); + } + + /** + * Tests that wp_plugin_update_row outputs a critical security alert notice for security closures. + * + * @covers ::wp_plugin_update_row + */ + public function test_wp_plugin_update_row_security_closure() { + require_once ABSPATH . 'wp-admin/includes/update.php'; + + $file = 'sec-plugin/sec-plugin.php'; + $plugin_data = array( + 'Name' => 'Security Plugin', + 'Version' => '1.0.0', + ); + + $transient = new stdClass(); + $transient->response = array( + $file => (object) array( + 'slug' => 'sec-plugin', + 'new_version' => '1.0.0', + 'closed' => true, + 'closed_date' => '2025-01-15', + 'closed_reason' => 'security-issue', + 'is_security' => true, + ), + ); + $transient->no_update = array(); + set_site_transient( 'update_plugins', $transient ); + + ob_start(); + wp_plugin_update_row( $file, $plugin_data ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'notice-error', $output ); + $this->assertStringContainsString( 'role="alert"', $output ); + $this->assertStringContainsString( 'Warning: This plugin was closed on', $output ); + $this->assertStringContainsString( 'due to a security issue and is no longer available for download. It should be uninstalled or replaced immediately.', $output ); + } + + /** + * Tests that wp_plugin_update_row outputs a warning notice for general closures. + * + * @covers ::wp_plugin_update_row + */ + public function test_wp_plugin_update_row_general_closure() { + require_once ABSPATH . 'wp-admin/includes/update.php'; + + $file = 'closed-plugin/closed-plugin.php'; + $plugin_data = array( + 'Name' => 'Closed Plugin', + 'Version' => '1.0.0', + ); + + $transient = new stdClass(); + $transient->response = array(); + $transient->no_update = array( + $file => (object) array( + 'slug' => 'closed-plugin', + 'new_version' => '1.0.0', + 'closed' => true, + 'closed_date' => '2024-06-20', + 'reason_text' => 'Author Request', + 'is_security' => false, + ), + ); + set_site_transient( 'update_plugins', $transient ); + + ob_start(); + wp_plugin_update_row( $file, $plugin_data ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'notice-warning', $output ); + $this->assertStringContainsString( 'role="status"', $output ); + $this->assertStringContainsString( 'Notice: This plugin was closed on', $output ); + $this->assertStringContainsString( 'Author Request', $output ); + $this->assertStringContainsString( 'and is no longer available for download.', $output ); + } + + /** + * Tests that wp_plugin_update_row outputs a warning notice for outdated plugins. + * + * @covers ::wp_plugin_update_row + */ + public function test_wp_plugin_update_row_outdated() { + require_once ABSPATH . 'wp-admin/includes/update.php'; + + $file = 'outdated-plugin/outdated-plugin.php'; + $plugin_data = array( + 'Name' => 'Outdated Plugin', + 'Version' => '1.0.0', + ); + + $transient = new stdClass(); + $transient->response = array(); + $transient->no_update = array( + $file => (object) array( + 'slug' => 'outdated-plugin', + 'new_version' => '1.0.0', + 'is_outdated' => true, + 'outdated_notice' => 'Custom outdated warning notice.', + ), + ); + set_site_transient( 'update_plugins', $transient ); + + ob_start(); + wp_plugin_update_row( $file, $plugin_data ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'notice-warning', $output ); + $this->assertStringContainsString( 'role="status"', $output ); + $this->assertStringContainsString( 'Custom outdated warning notice.', $output ); + } + + /** + * Tests that WP_Plugins_List_Table renders badges for closed and outdated plugins. + * + * @covers WP_Plugins_List_Table::single_row + */ + public function test_wp_plugins_list_table_badges() { + require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php'; + require_once ABSPATH . 'wp-admin/includes/screen.php'; + require_once ABSPATH . 'wp-admin/includes/template.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-plugins-list-table.php'; + + set_current_screen( 'plugins.php' ); + + $transient = new stdClass(); + $transient->response = array( + 'sec/sec.php' => (object) array( + 'slug' => 'sec', + 'closed' => true, + 'is_security' => true, + ), + ); + $transient->no_update = array( + 'gen/gen.php' => (object) array( + 'slug' => 'gen', + 'closed' => true, + 'is_security' => false, + ), + 'old/old.php' => (object) array( + 'slug' => 'old', + 'is_outdated' => true, + ), + ); + set_site_transient( 'update_plugins', $transient ); + + $table = new WP_Plugins_List_Table(); + + // Security closed plugin. + ob_start(); + $table->single_row( + array( + 'sec/sec.php', + array( + 'Name' => 'Security Plugin', + 'Version' => '1.0.0', + 'Description' => 'Test', + ), + ) + ); + $output_sec = ob_get_clean(); + $this->assertStringContainsString( 'plugin-status-badge-security', $output_sec ); + $this->assertStringContainsString( 'Closed (Security)', $output_sec ); + + // General closed plugin. + ob_start(); + $table->single_row( + array( + 'gen/gen.php', + array( + 'Name' => 'General Plugin', + 'Version' => '1.0.0', + 'Description' => 'Test', + ), + ) + ); + $output_gen = ob_get_clean(); + $this->assertStringContainsString( 'plugin-status-badge plugin-status-badge-closed', $output_gen ); + $this->assertStringContainsString( 'Closed', $output_gen ); + + // Outdated plugin. + ob_start(); + $table->single_row( + array( + 'old/old.php', + array( + 'Name' => 'Old Plugin', + 'Version' => '1.0.0', + 'Description' => 'Test', + ), + ) + ); + $output_old = ob_get_clean(); + $this->assertStringContainsString( 'plugin-status-badge plugin-status-badge-outdated', $output_old ); + $this->assertStringContainsString( 'Outdated', $output_old ); + } + + /** + * Tests that plugins_api handles error => closed without converting to WP_Error. + * + * @covers ::plugins_api + */ + public function test_plugins_api_preserves_closed_plugin_object() { + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + + add_filter( + 'pre_http_request', + static function ( $response, $parsed_args, $url ) { + if ( false === strpos( $url, 'api.wordpress.org/plugins/info' ) ) { + return $response; + } + + return array( + 'headers' => array(), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'body' => wp_json_encode( + array( + 'error' => 'closed', + 'name' => 'Test Closed Plugin', + 'slug' => 'test-closed-plugin', + 'description' => 'This plugin has been closed.', + 'status' => 'closed', + 'closed' => true, + 'closed_date' => '2025-01-15', + 'reason' => 'security-issue', + 'reason_text' => 'Security Issue', + 'is_security' => true, + 'is_outdated' => false, + ) + ), + 'cookies' => array(), + 'filename' => null, + ); + }, + 10, + 3 + ); + + $result = plugins_api( 'plugin_information', array( 'slug' => 'test-closed-plugin' ) ); + + $this->assertNotWPError( $result ); + $this->assertIsObject( $result ); + $this->assertSame( 'closed', $result->error ); + $this->assertTrue( $result->closed ); + $this->assertTrue( $result->is_security ); + $this->assertSame( 'security-issue', $result->reason ); + } + + /** + * Tests Site Health check for closed plugins. + * + * @covers WP_Site_Health::get_test_closed_plugins_and_themes + */ + public function test_site_health_closed_plugins_check() { + require_once ABSPATH . 'wp-admin/includes/class-wp-site-health.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $mock_plugins = array( + 'safe-plugin/safe-plugin.php' => array( + 'Name' => 'Safe Plugin', + 'Version' => '1.0.0', + ), + 'sec-plugin/sec-plugin.php' => array( + 'Name' => 'Security Plugin', + 'Version' => '1.0.0', + ), + 'closed-plugin/closed-plugin.php' => array( + 'Name' => 'Closed Plugin', + 'Version' => '1.0.0', + ), + 'old-plugin/old-plugin.php' => array( + 'Name' => 'Old Plugin', + 'Version' => '1.0.0', + ), + ); + + wp_cache_set( 'plugins', array( '' => $mock_plugins ), 'plugins' ); + + $site_health = new WP_Site_Health(); + + // Case 1: No closed active plugins -> good status. + update_option( 'active_plugins', array( 'safe-plugin/safe-plugin.php' ) ); + delete_site_transient( 'update_plugins' ); + + $res = $site_health->get_test_closed_plugins_and_themes(); + $this->assertSame( 'good', $res['status'] ); + + // Case 2: Active plugin closed for security -> critical status. + $transient = new stdClass(); + $transient->response = array( + 'sec-plugin/sec-plugin.php' => (object) array( + 'slug' => 'sec-plugin', + 'closed' => true, + 'is_security' => true, + 'closed_date' => '2025-01-15', + ), + ); + set_site_transient( 'update_plugins', $transient ); + update_option( 'active_plugins', array( 'sec-plugin/sec-plugin.php' ) ); + + $res = $site_health->get_test_closed_plugins_and_themes(); + $this->assertSame( 'critical', $res['status'] ); + $this->assertStringContainsString( 'security issues', $res['label'] ); + $this->assertStringContainsString( 'action=deactivate', $res['description'] ); + + // Case 3: Active plugin closed for non-security -> recommended status. + $transient = new stdClass(); + $transient->response = array( + 'closed-plugin/closed-plugin.php' => (object) array( + 'slug' => 'closed-plugin', + 'closed' => true, + 'is_security' => false, + ), + ); + set_site_transient( 'update_plugins', $transient ); + update_option( 'active_plugins', array( 'closed-plugin/closed-plugin.php' ) ); + + $res = $site_health->get_test_closed_plugins_and_themes(); + $this->assertSame( 'recommended', $res['status'] ); + $this->assertStringContainsString( 'no longer maintained or available', $res['label'] ); + + // Case 4: Active plugin is outdated -> recommended status. + $transient = new stdClass(); + $transient->response = array(); + $transient->no_update = array( + 'old-plugin/old-plugin.php' => (object) array( + 'slug' => 'old-plugin', + 'is_outdated' => true, + ), + ); + set_site_transient( 'update_plugins', $transient ); + update_option( 'active_plugins', array( 'old-plugin/old-plugin.php' ) ); + + $res = $site_health->get_test_closed_plugins_and_themes(); + $this->assertSame( 'recommended', $res['status'] ); + } +} diff --git a/tests/phpunit/tests/theme/closedThemes.php b/tests/phpunit/tests/theme/closedThemes.php new file mode 100644 index 0000000000000..aa95f9151f1d7 --- /dev/null +++ b/tests/phpunit/tests/theme/closedThemes.php @@ -0,0 +1,285 @@ + array( + $current_theme => array( + 'theme' => $current_theme, + 'new_version' => '99.0.0', + 'url' => 'https://wordpress.org/themes/' . $current_theme . '/', + 'package' => '', + 'closed' => true, + 'is_suspended' => true, + 'is_security' => true, + 'closed_date' => '2025-01-15', + 'reason' => 'security-issue', + 'reason_text' => 'Security Issue', + ), + ), + 'translations' => array(), + 'no_update' => array( + 'outdated-theme' => array( + 'theme' => 'outdated-theme', + 'new_version' => '1.0.0', + 'url' => 'https://wordpress.org/themes/outdated-theme/', + 'is_outdated' => true, + 'outdated_notice' => 'Theme has not been updated in over 2 years.', + ), + ), + 'closed' => array( + 'suspended-theme' => array( + 'theme' => 'suspended-theme', + 'status' => 'suspend', + 'closed' => true, + 'is_suspended' => true, + 'closed_date' => '2024-03-01', + 'reason_text' => 'Author request', + ), + ), + ); + + return array( + 'headers' => array(), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'body' => wp_json_encode( $mock_body ), + 'cookies' => array(), + 'filename' => null, + ); + }, + 10, + 3 + ); + + delete_site_transient( 'update_themes' ); + wp_update_themes( array( 'core' => 'test' ) ); + + $transient = get_site_transient( 'update_themes' ); + $this->assertIsObject( $transient, 'Transient should be an object.' ); + $this->assertArrayHasKey( $current_theme, $transient->response ); + + $sec_item = $transient->response[ $current_theme ]; + $this->assertTrue( $sec_item['closed'] ); + $this->assertTrue( $sec_item['is_suspended'] ); + $this->assertTrue( $sec_item['is_security'] ); + $this->assertSame( '2025-01-15', $sec_item['closed_date'] ); + $this->assertSame( 'security-issue', $sec_item['reason'] ); + + $this->assertArrayHasKey( 'outdated-theme', $transient->no_update ); + $outdated_item = $transient->no_update['outdated-theme']; + $this->assertTrue( $outdated_item['is_outdated'] ); + $this->assertSame( 'Theme has not been updated in over 2 years.', $outdated_item['outdated_notice'] ); + + $this->assertArrayHasKey( 'suspended-theme', $transient->no_update ); + $suspended_item = $transient->no_update['suspended-theme']; + $this->assertTrue( $suspended_item['closed'] ); + $this->assertTrue( $suspended_item['is_suspended'] ); + $this->assertSame( '2024-03-01', $suspended_item['closed_date'] ); + } + + /** + * Tests that wp_prepare_themes_for_js includes closure, suspension, and outdated metadata. + * + * @covers ::wp_prepare_themes_for_js + */ + public function test_wp_prepare_themes_for_js_includes_closure_metadata() { + require_once ABSPATH . 'wp-admin/includes/theme.php'; + + $current_theme = get_stylesheet(); + + $transient = new stdClass(); + $transient->response = array( + $current_theme => array( + 'theme' => $current_theme, + 'new_version' => '99.0.0', + 'closed' => true, + 'is_suspended' => true, + 'is_security' => true, + 'is_outdated' => true, + 'closed_date' => '2025-01-15', + 'reason_text' => 'Security Issue', + 'outdated_notice' => 'Custom theme outdated message', + ), + ); + $transient->no_update = array(); + set_site_transient( 'update_themes', $transient ); + + $prepared = wp_prepare_themes_for_js(); + $this->assertIsArray( $prepared ); + + $found_theme = null; + foreach ( $prepared as $item ) { + if ( $item['id'] === $current_theme ) { + $found_theme = $item; + break; + } + } + + $this->assertNotNull( $found_theme, 'Active theme must be in prepared themes.' ); + $this->assertTrue( $found_theme['closed'] ); + $this->assertTrue( $found_theme['is_suspended'] ); + $this->assertTrue( $found_theme['is_security'] ); + $this->assertTrue( $found_theme['is_outdated'] ); + $this->assertSame( '2025-01-15', $found_theme['closedDate'] ); + $this->assertSame( 'Security Issue', $found_theme['closedReason'] ); + $this->assertSame( 'Custom theme outdated message', $found_theme['outdatedNotice'] ); + } + + /** + * Tests that themes_api handles error => closed without converting to WP_Error. + * + * @covers ::themes_api + */ + public function test_themes_api_preserves_closed_theme_object() { + require_once ABSPATH . 'wp-admin/includes/theme.php'; + + add_filter( + 'pre_http_request', + static function ( $response, $parsed_args, $url ) { + if ( false === strpos( $url, 'api.wordpress.org/themes/info' ) ) { + return $response; + } + + return array( + 'headers' => array(), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'body' => wp_json_encode( + array( + 'error' => 'closed', + 'name' => 'Test Closed Theme', + 'slug' => 'test-closed-theme', + 'description' => 'This theme has been closed.', + 'status' => 'suspend', + 'closed' => true, + 'is_closed' => true, + 'is_suspended' => true, + 'is_security' => true, + 'closed_date' => '2025-01-15', + 'reason' => 'security-issue', + 'reason_text' => 'Security Issue', + 'is_outdated' => false, + ) + ), + 'cookies' => array(), + 'filename' => null, + ); + }, + 10, + 3 + ); + + $result = themes_api( 'theme_information', array( 'slug' => 'test-closed-theme' ) ); + + $this->assertNotWPError( $result ); + $this->assertIsObject( $result ); + $this->assertSame( 'closed', $result->error ); + $this->assertTrue( $result->closed ); + $this->assertTrue( $result->is_suspended ); + $this->assertTrue( $result->is_security ); + $this->assertSame( 'security-issue', $result->reason ); + } + + /** + * Tests Site Health check for closed and outdated themes. + * + * @covers WP_Site_Health::get_test_closed_plugins_and_themes + */ + public function test_site_health_closed_themes_check() { + require_once ABSPATH . 'wp-admin/includes/class-wp-site-health.php'; + + $site_health = new WP_Site_Health(); + $current_theme = get_stylesheet(); + + // Case 1: Active theme is normal -> good status. + delete_site_transient( 'update_themes' ); + delete_site_transient( 'update_plugins' ); + $res = $site_health->get_test_closed_plugins_and_themes(); + $this->assertSame( 'good', $res['status'] ); + + // Case 2: Active theme closed for security -> critical status. + $transient = new stdClass(); + $transient->response = array( + $current_theme => array( + 'theme' => $current_theme, + 'closed' => true, + 'is_security' => true, + 'closed_date' => '2025-01-15', + ), + ); + $transient->no_update = array(); + set_site_transient( 'update_themes', $transient ); + + $res = $site_health->get_test_closed_plugins_and_themes(); + $this->assertSame( 'critical', $res['status'] ); + $this->assertStringContainsString( 'security issues', $res['label'] ); + + // Case 3: Active theme suspended (non-security) -> recommended status. + $transient->response = array( + $current_theme => array( + 'theme' => $current_theme, + 'status' => 'suspend', + 'closed' => true, + 'is_suspended' => true, + 'is_security' => false, + ), + ); + set_site_transient( 'update_themes', $transient ); + + $res = $site_health->get_test_closed_plugins_and_themes(); + $this->assertSame( 'recommended', $res['status'] ); + $this->assertStringContainsString( 'no longer maintained or available', $res['label'] ); + + // Case 4: Active theme outdated -> recommended status. + $transient->response = array(); + $transient->no_update = array( + $current_theme => array( + 'theme' => $current_theme, + 'is_outdated' => true, + ), + ); + set_site_transient( 'update_themes', $transient ); + + $res = $site_health->get_test_closed_plugins_and_themes(); + $this->assertSame( 'recommended', $res['status'] ); + } +} From 85cb10e52fe8e721c66c38d616bcaf1e3b92ff69 Mon Sep 17 00:00:00 2001 From: "Mr.Alidoosti" Date: Sun, 4 Oct 2026 14:49:39 +0330 Subject: [PATCH 2/2] test: add set_up method and update plugin test fixtures in closed plugins tests --- tests/phpunit/tests/plugins/closedPlugins.php | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/tests/phpunit/tests/plugins/closedPlugins.php b/tests/phpunit/tests/plugins/closedPlugins.php index b98b3c6677ce9..e0d04521e2a4b 100644 --- a/tests/phpunit/tests/plugins/closedPlugins.php +++ b/tests/phpunit/tests/plugins/closedPlugins.php @@ -8,6 +8,26 @@ */ class Tests_Plugins_ClosedPlugins extends WP_UnitTestCase { + /** + * Sets up the test fixture. + */ + public function set_up() { + parent::set_up(); + + require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php'; + require_once ABSPATH . 'wp-admin/includes/screen.php'; + require_once ABSPATH . 'wp-admin/includes/template.php'; + + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + if ( is_multisite() ) { + grant_super_admin( $admin_id ); + set_current_screen( 'plugins-network' ); + } else { + set_current_screen( 'plugins.php' ); + } + wp_set_current_user( $admin_id ); + } + /** * Cleans up options and transients after each test. */ @@ -17,6 +37,7 @@ public function tear_down() { wp_cache_delete( 'plugins', 'plugins' ); remove_all_filters( 'pre_http_request' ); remove_all_filters( 'all_plugins' ); + set_current_screen( 'front' ); parent::tear_down(); } @@ -285,38 +306,36 @@ public function test_wp_plugins_list_table_badges() { require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-plugins-list-table.php'; - set_current_screen( 'plugins.php' ); - $transient = new stdClass(); $transient->response = array( - 'sec/sec.php' => (object) array( - 'slug' => 'sec', + 'hello.php' => (object) array( + 'slug' => 'hello', 'closed' => true, 'is_security' => true, ), ); $transient->no_update = array( - 'gen/gen.php' => (object) array( - 'slug' => 'gen', + 'internationalized-plugin.php' => (object) array( + 'slug' => 'internationalized-plugin', 'closed' => true, 'is_security' => false, ), - 'old/old.php' => (object) array( - 'slug' => 'old', + 'custom-internationalized-plugin/custom-internationalized-plugin.php' => (object) array( + 'slug' => 'custom-internationalized-plugin', 'is_outdated' => true, ), ); set_site_transient( 'update_plugins', $transient ); - $table = new WP_Plugins_List_Table(); + $table = new WP_Plugins_List_Table( array( 'screen' => get_current_screen() ) ); // Security closed plugin. ob_start(); $table->single_row( array( - 'sec/sec.php', + 'hello.php', array( - 'Name' => 'Security Plugin', + 'Name' => 'Hello Dolly', 'Version' => '1.0.0', 'Description' => 'Test', ), @@ -330,9 +349,9 @@ public function test_wp_plugins_list_table_badges() { ob_start(); $table->single_row( array( - 'gen/gen.php', + 'internationalized-plugin.php', array( - 'Name' => 'General Plugin', + 'Name' => 'Internationalized Plugin', 'Version' => '1.0.0', 'Description' => 'Test', ), @@ -346,9 +365,9 @@ public function test_wp_plugins_list_table_badges() { ob_start(); $table->single_row( array( - 'old/old.php', + 'custom-internationalized-plugin/custom-internationalized-plugin.php', array( - 'Name' => 'Old Plugin', + 'Name' => 'Custom Internationalized Plugin', 'Version' => '1.0.0', 'Description' => 'Test', ),