diff --git a/classes/class-base.php b/classes/class-base.php index 7fa27f1ff3..ecd25514e7 100644 --- a/classes/class-base.php +++ b/classes/class-base.php @@ -20,6 +20,7 @@ * @method \Progress_Planner\Rest\Tasks get_rest__tasks() * @method \Progress_Planner\Todo get_todo() * @method \Progress_Planner\Utils\Onboard get_utils__onboard() + * @method \Progress_Planner\Utils\Security_Update_Monitor get_utils__security_update_monitor() * @method \Progress_Planner\Utils\Playground get_utils__playground() * @method \Progress_Planner\Admin\Page get_admin__page() * @method \Progress_Planner\Admin\Tour get_admin__tour() @@ -120,6 +121,9 @@ public function init() { $this->get_suggested_tasks(); + // Watches for core security releases; must run on front-end/cron requests too. + $this->get_utils__security_update_monitor(); + $this->get_admin__editor(); $this->get_actions__content(); diff --git a/classes/class-suggested-tasks.php b/classes/class-suggested-tasks.php index 751bb4dd8f..4fd79e2422 100644 --- a/classes/class-suggested-tasks.php +++ b/classes/class-suggested-tasks.php @@ -156,23 +156,34 @@ public function delete_activity( string $task_id ): void { * @return void */ public function on_automatic_updates_complete(): void { - $pending_tasks = \progress_planner()->get_suggested_tasks_db()->get( - [ - 'numberposts' => 1, - 'post_status' => 'publish', - 'provider_id' => 'update-core', - 'date_query' => [ [ 'after' => 'this Monday' ] ], - ] - ); + $providers = [ + // The repetitive update-core task only counts within the current week. + 'update-core' => [ 'date_query' => [ [ 'after' => 'this Monday' ] ] ], + // The security-update task persists until the update is installed. + 'security-update' => [], + ]; - if ( empty( $pending_tasks ) ) { - return; - } + foreach ( $providers as $provider_id => $extra_args ) { + $pending_tasks = \progress_planner()->get_suggested_tasks_db()->get( + \array_merge( + [ + 'numberposts' => 1, + 'post_status' => 'publish', + 'provider_id' => $provider_id, + ], + $extra_args + ) + ); + + if ( empty( $pending_tasks ) ) { + continue; + } - \progress_planner()->get_suggested_tasks_db()->update_recommendation( $pending_tasks[0]->ID, [ 'post_status' => 'trash' ] ); + \progress_planner()->get_suggested_tasks_db()->update_recommendation( $pending_tasks[0]->ID, [ 'post_status' => 'trash' ] ); - // Insert an activity. - $this->insert_activity( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $pending_tasks[0]->post_name ) ); + // Insert an activity. + $this->insert_activity( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $pending_tasks[0]->post_name ) ); + } } /** @@ -484,6 +495,12 @@ public function rest_api_tax_query( $args, $request ) { $include_providers = \array_intersect( $include_providers, $request_providers ); } + // While a security-update task is pending, it is the only recommendation + // shown to users who can install it. + if ( $this->should_lock_down_recommendations( $request, $include_providers ) ) { + $include_providers = [ 'security-update' ]; + } + $tax_query[] = [ 'taxonomy' => 'prpl_recommendations_provider', 'field' => 'slug', @@ -508,6 +525,38 @@ public function rest_api_tax_query( $args, $request ) { return $args; } + /** + * Check whether a REST recommendations query must be locked down to the security-update task. + * + * The lockdown only applies to publish-status queries from users who can install + * the update. The user's own to-do list (provider "user") and pending-celebration + * queries are never locked down. + * + * @param \WP_REST_Request $request The request object. + * @param array $include_providers The provider IDs available to the current user. + * + * @return bool + */ + private function should_lock_down_recommendations( $request, $include_providers ) { + // Only publish-status queries are locked down (celebrations & snoozed tasks flow normally). + $statuses = isset( $request['status'] ) ? (array) $request['status'] : [ 'publish' ]; + if ( [ 'publish' ] !== \array_values( $statuses ) ) { + return false; + } + + // The user's own to-do list is never locked down. + if ( isset( $request['provider'] ) && [ 'user' ] === \explode( ',', $request['provider'] ) ) { + return false; + } + + // Only users who can install the update are locked down. + if ( ! \in_array( 'security-update', $include_providers, true ) ) { + return false; + } + + return \progress_planner()->get_utils__security_update_monitor()->is_security_lockdown_active(); + } + /** * Sanitize a recommendation before it is inserted or updated via the REST API. * @@ -553,7 +602,7 @@ public function rest_prepare_recommendation( $response, $post ) { if ( $provider ) { $response->data['prpl_provider'] = $provider_term[0]; // Link should be added during run time, since it is not added for users without required capability. - $response->data['meta']['prpl_url'] = $response->data['meta']['prpl_url'] && $provider->capability_required() + $response->data['meta']['prpl_url'] = ! empty( $response->data['meta']['prpl_url'] ) && $provider->capability_required() ? \esc_url( (string) $response->data['meta']['prpl_url'] ) : ''; @@ -600,6 +649,27 @@ public function get_tasks_in_rest_format( array $args = [] ) { ] ); + // While a security-update task is pending, it is the only recommendation + // served to users who can install it. User to-do lists and non-publish + // statuses (e.g. pending celebrations) are not affected. + if ( [ 'publish' ] === \array_values( (array) $args['post_status'] ) + && [ 'user' ] !== $args['include_provider'] + && \in_array( + 'security-update', + \array_map( + static function ( $provider ) { + return $provider->get_provider_id(); + }, + $this->tasks_manager->get_task_providers_available_for_user() + ), + true + ) + && \progress_planner()->get_utils__security_update_monitor()->is_security_lockdown_active() + ) { + $args['include_provider'] = [ 'security-update' ]; + $args['exclude_provider'] = []; + } + // Build query args for get_tasks_by. $query_args = [ 'post_status' => $args['post_status'], diff --git a/classes/suggested-tasks/class-tasks-manager.php b/classes/suggested-tasks/class-tasks-manager.php index 64b2aefb51..86331f8c30 100644 --- a/classes/suggested-tasks/class-tasks-manager.php +++ b/classes/suggested-tasks/class-tasks-manager.php @@ -22,6 +22,7 @@ use Progress_Planner\Suggested_Tasks\Providers\Permalink_Structure; use Progress_Planner\Suggested_Tasks\Providers\Php_Version; use Progress_Planner\Suggested_Tasks\Providers\Search_Engine_Visibility; +use Progress_Planner\Suggested_Tasks\Providers\Security_Update; use Progress_Planner\Suggested_Tasks\Tasks_Interface; use Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast\Add_Yoast_Providers; use Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO\Add_AIOSEO_Providers; @@ -64,6 +65,7 @@ public function __construct() { new Content_Create(), new Content_Review(), new Core_Update(), + new Security_Update(), new Blog_Description(), new Debug_Display(), new Disable_Comments(), diff --git a/classes/suggested-tasks/providers/class-security-update.php b/classes/suggested-tasks/providers/class-security-update.php new file mode 100644 index 0000000000..a0555c0f86 --- /dev/null +++ b/classes/suggested-tasks/providers/class-security-update.php @@ -0,0 +1,298 @@ +get_version_from_task_id( $task_id ); + + return '' === $version + ? ! $this->should_add_task() + : \version_compare( Security_Update_Monitor::get_effective_installed_version(), $version, '>=' ); + } + + /** + * Check if the published task is still relevant. + * + * A task stays relevant while its version is still offered, or once it has been + * installed (so evaluation can celebrate it). A withdrawn offer makes the task + * irrelevant and it is deleted without celebration. + * + * @return bool + */ + public function is_task_relevant() { + $tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider_id' => static::PROVIDER_ID, + ] + ); + + if ( empty( $tasks ) ) { + return true; + } + + $version = $this->get_version_from_task_id( (string) $tasks[0]->post_name ); + if ( '' === $version ) { + return false; + } + + // Already installed: keep the task so evaluation can celebrate it. + if ( \version_compare( Security_Update_Monitor::get_effective_installed_version(), $version, '>=' ) ) { + return true; + } + + // Otherwise the task is only relevant while its version is still offered. + $pending = Security_Update_Monitor::get_pending_security_update(); + + return null !== $pending && $pending['offered'] === $version; + } + + /** + * Get an array of tasks to inject. + * + * Removes published tasks for superseded or withdrawn releases before + * injecting the task for the currently offered release. + * + * @return array + */ + public function get_tasks_to_inject() { + $current_task_id = $this->should_add_task() ? $this->get_task_id() : ''; + + foreach ( (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider_id' => static::PROVIDER_ID, + ] + ) as $task ) { + $task_id = \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( (string) $task->post_name ); + $version = $this->get_version_from_task_id( $task_id ); + + // Keep the task for the current offer, and completed-but-not-yet-celebrated tasks. + if ( $task_id === $current_task_id + || ( '' !== $version && \version_compare( Security_Update_Monitor::get_effective_installed_version(), $version, '>=' ) ) + ) { + continue; + } + + \progress_planner()->get_suggested_tasks_db()->delete_recommendation( $task->ID ); + } + + return parent::get_tasks_to_inject(); + } + + /** + * Add the offered and installed versions to the injected task data. + * + * @param array $task_data The task data. + * + * @return array + */ + protected function modify_injection_task_data( $task_data ) { + $pending = Security_Update_Monitor::get_pending_security_update(); + + if ( null !== $pending ) { + $task_data['offered_version'] = $pending['offered']; + $task_data['installed_version'] = $pending['installed']; + } + + return $task_data; + } + + /** + * Add task actions specific to this task. + * + * The wp-admin Updates page only lists the latest release (get_core_updates() + * skips "autoupdate" offers), so a branch-behind site would only be offered the + * next major there. This form posts the exact branch version and locale to + * core's own do-core-upgrade handler, which accepts any offer in the transient. + * + * @param array $data The task data. + * @param array $actions The existing actions. + * + * @return array + */ + public function add_task_actions( $data = [], $actions = [] ) { + $offer = Security_Update_Monitor::get_pending_security_update_offer(); + + if ( null !== $offer ) { + $form_action = \is_multisite() + ? \network_admin_url( 'update-core.php?action=do-core-upgrade' ) + : \admin_url( 'update-core.php?action=do-core-upgrade' ); + $locale = $offer->locale ?? 'en_US'; + + $actions[] = [ + 'priority' => 5, + 'html' => '
' + . \wp_nonce_field( 'upgrade-core', '_wpnonce', true, false ) + . '' + . '' + . '' + . '' + . '
', + ]; + } + + $actions[] = [ + 'priority' => 10, + 'html' => '' . \esc_html__( 'Go to the Updates page', 'progress-planner' ) . '', + ]; + + return $actions; + } + + /** + * Extract the version from a versioned task ID. + * + * @param string $task_id The task ID (e.g. security-update-6-8-2). + * + * @return string The version (e.g. 6.8.2), or an empty string. + */ + private function get_version_from_task_id( $task_id ) { + if ( ! \preg_match( '/^' . static::PROVIDER_ID . '-(\d+(?:-\d+)*)$/', $task_id, $matches ) ) { + return ''; + } + + return \str_replace( '-', '.', $matches[1] ); + } +} diff --git a/classes/utils/class-security-update-monitor.php b/classes/utils/class-security-update-monitor.php new file mode 100644 index 0000000000..ed8f5b6dc1 --- /dev/null +++ b/classes/utils/class-security-update-monitor.php @@ -0,0 +1,360 @@ +maybe_alert( \get_site_transient( 'update_core' ) ); + } + + /** + * Alert site admins (and the SaaS) about a pending security update, once per offered version. + * + * @param mixed $transient The update_core site transient value. + * + * @return void + */ + public function maybe_alert( $transient ) { + if ( \is_multisite() && ! \is_main_site() ) { + return; + } + + $update = self::get_pending_security_update( \is_object( $transient ) ? $transient : null ); + if ( null === $update ) { + return; + } + + // One alert per offered version. + if ( \get_site_option( self::ALERTED_VERSION_OPTION ) === $update['offered'] ) { + return; + } + + // Cron and a concurrent admin pageview can both fire this; take a short-lived lock. + if ( ! \add_option( self::LOCK_OPTION, \time(), '', false ) ) { + $lock_time = (int) \get_option( self::LOCK_OPTION ); + if ( $lock_time > \time() - 30 ) { + return; + } + \update_option( self::LOCK_OPTION, \time(), false ); + } + + try { + $this->send_admin_email( $update['installed'], $update['offered'] ); + + // Mark as alerted even if sending failed, to avoid alert storms on every request. + // The subscriber email is sent by progressplanner.com, which watches the + // wordpress.org releases feed and reads this site's version via get-stats. + \update_site_option( self::ALERTED_VERSION_OPTION, $update['offered'] ); + } finally { + \delete_option( self::LOCK_OPTION ); + } + } + + /** + * Email every user who can install core updates about the security release. + * + * @param string $installed The installed version. + * @param string $offered The offered version. + * + * @return void + */ + private function send_admin_email( $installed, $offered ) { + $subject = \sprintf( + /* translators: 1: The site name, 2: The offered WordPress version. */ + \__( '[%1$s] Critical: WordPress %2$s security update available', 'progress-planner' ), + \wp_specialchars_decode( (string) \get_option( 'blogname' ), ENT_QUOTES ), + $offered + ); + + // Onboarded sites go to the Progress Planner dashboard, which carries the + // one-click branch-pinned update button. The wp-admin Updates page only + // offers the latest major, so it is just the fallback for sites that have + // not onboarded yet (their dashboard would show the welcome screen). + $is_onboarded = \progress_planner()->is_privacy_policy_accepted(); + $action_url = $is_onboarded + ? \admin_url( 'admin.php?page=progress-planner' ) + : \admin_url( 'update-core.php' ); + + $paragraphs = [ + \sprintf( + /* translators: 1: The offered WordPress version, 2: The installed WordPress version. */ + \__( 'A WordPress security release is available: version %1$s (your site runs %2$s).', 'progress-planner' ), + $offered, + $installed + ), + \sprintf( + /* translators: %s: The URL where the update can be installed. */ + \__( + 'Security issues in WordPress are typically exploited within hours of a release, so please update as soon as possible: + +%s', + 'progress-planner' + ), + $action_url + ), + ]; + + if ( $is_onboarded ) { + $paragraphs[] = \__( 'Your Progress Planner dashboard has a one-click button to install exactly this update; the task will be marked complete once your site is updated.', 'progress-planner' ); + } + + $paragraphs[] = \sprintf( + /* translators: %s: The URL of the WordPress backups documentation. */ + \__( 'If you have a backup solution, make a fresh backup before updating — but do not postpone the update if you have none. Learn more about backups: %s', 'progress-planner' ), + 'https://wordpress.org/documentation/article/wordpress-backups/' + ); + $paragraphs[] = \__( 'If your site has automatic updates enabled, it may install this update by itself — in that case, please verify that the update has been applied.', 'progress-planner' ); + $paragraphs[] = \__( 'This alert was sent by the Progress Planner plugin.', 'progress-planner' ); + + $message = \implode( "\n\n", $paragraphs ); + + foreach ( $this->get_recipients() as $email ) { + \wp_mail( $email, $subject, $message ); + } + } + + /** + * Get the email addresses of all users who can install core updates. + * + * @return string[] + */ + private function get_recipients() { + if ( \is_multisite() ) { + $emails = []; + foreach ( \get_super_admins() as $login ) { + $user = \get_user_by( 'login', $login ); + if ( $user ) { + $emails[] = $user->user_email; + } + } + + return \array_values( \array_unique( $emails ) ); + } + + $emails = []; + foreach ( \get_users( [ 'capability' => 'update_core' ] ) as $user ) { + if ( $user instanceof \WP_User ) { + $emails[] = $user->user_email; + } + } + + return \array_values( \array_unique( $emails ) ); + } + + /** + * Check whether a security-update task is currently published. + * + * While one is published, the recommendations UI is locked down to that task. + * + * @return bool + */ + public function is_security_lockdown_active() { + return ! empty( + \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider_id' => 'security-update', + 'numberposts' => 1, + ] + ) + ); + } + + /** + * Check whether an offered version is a security release, compared to the installed one. + * + * A security release is a patch release on the same branch: same major.minor, + * with a higher patch number. Non-stable versions (alpha/beta/RC) never qualify. + * + * @param string $installed The installed WordPress version. + * @param string $offered The offered WordPress version. + * + * @return bool + */ + public static function is_security_release( $installed, $offered ) { + // Non-stable versions (e.g. 6.9-alpha-59000, 6.9-RC1) never qualify. + if ( false !== \strpos( $installed, '-' ) || false !== \strpos( $offered, '-' ) ) { + return false; + } + + $installed_parts = self::get_version_parts( $installed ); + $offered_parts = self::get_version_parts( $offered ); + + if ( null === $installed_parts || null === $offered_parts ) { + return false; + } + + return $installed_parts[0] === $offered_parts[0] + && $installed_parts[1] === $offered_parts[1] + && $offered_parts[2] > $installed_parts[2]; + } + + /** + * Get the pending security update from an update_core transient, if any. + * + * @param object|null $transient The update_core site transient. Defaults to the stored transient. + * @param string|null $installed The installed version. Defaults to the running WordPress version. + * + * @return array{installed: string, offered: string}|null + */ + public static function get_pending_security_update( $transient = null, $installed = null ) { + if ( null === $installed ) { + $installed = self::get_installed_version(); + } + + $offer = self::get_pending_security_update_offer( $transient, $installed ); + + return null === $offer + ? null + : [ + 'installed' => $installed, + 'offered' => $offer->current, + ]; + } + + /** + * Get the update offer object for the pending security update, if any. + * + * The offer carries the exact version and locale needed to run a + * version-pinned core upgrade through wp-admin/update-core.php. + * + * @param object|null $transient The update_core site transient. Defaults to the stored transient. + * @param string|null $installed The installed version. Defaults to the running WordPress version. + * + * @return object{response: string, current: string, locale?: string}|null + */ + public static function get_pending_security_update_offer( $transient = null, $installed = null ) { + if ( null === $transient ) { + $transient = \get_site_transient( 'update_core' ); + } + + if ( null === $installed ) { + $installed = self::get_installed_version(); + } + + if ( ! \is_object( $transient ) || ! isset( $transient->updates ) || ! \is_array( $transient->updates ) ) { + return null; + } + + /** + * The best matching offer. + * + * @var object{response: string, current: string, locale?: string}|null $offer + */ + $offer = null; + foreach ( $transient->updates as $update ) { + // Same-branch point releases are offered with response "autoupdate" (only the + // newest release gets "upgrade"), so a branch-behind site (e.g. 6.9.1 when + // 7.0.x is current) receives its 6.9.x security patch as an autoupdate offer. + if ( ! \is_object( $update ) + || ! isset( $update->response, $update->current ) + || ! \in_array( $update->response, [ 'upgrade', 'autoupdate' ], true ) + || ! \is_string( $update->current ) + || ! self::is_security_release( $installed, $update->current ) + ) { + continue; + } + + if ( null === $offer || \version_compare( $update->current, $offer->current, '>' ) ) { + $offer = $update; + } + } + + return $offer; + } + + /** + * Get the effective installed WordPress version. + * + * Returns the higher of the running version and the version recorded by the + * last update check (the transient's version_checked), so a just-installed + * update is recognized as soon as the update check has run. + * + * @return string + */ + public static function get_effective_installed_version() { + $installed = self::get_installed_version(); + $transient = \get_site_transient( 'update_core' ); + + if ( \is_object( $transient ) + && isset( $transient->version_checked ) + && \is_string( $transient->version_checked ) + && \version_compare( $transient->version_checked, $installed, '>' ) + ) { + return $transient->version_checked; + } + + return $installed; + } + + /** + * Get the installed WordPress version. + * + * @return string + */ + private static function get_installed_version() { + // wp_get_wp_version() exists since WP 6.7; the plugin supports 6.6. + if ( \function_exists( 'wp_get_wp_version' ) ) { + return \wp_get_wp_version(); + } + + return isset( $GLOBALS['wp_version'] ) && \is_string( $GLOBALS['wp_version'] ) ? $GLOBALS['wp_version'] : ''; + } + + /** + * Split a version string into major, minor and patch integers. + * + * @param string $version The version string. + * + * @return array{int, int, int}|null Null when the version is not parsable. + */ + private static function get_version_parts( $version ) { + if ( ! \preg_match( '/^(\d+)\.(\d+)(?:\.(\d+))?$/', $version, $matches ) ) { + return null; + } + + return [ (int) $matches[1], (int) $matches[2], isset( $matches[3] ) ? (int) $matches[3] : 0 ]; + } +} diff --git a/classes/utils/class-system-status.php b/classes/utils/class-system-status.php index a7d7192963..ddf7740273 100644 --- a/classes/utils/class-system-status.php +++ b/classes/utils/class-system-status.php @@ -9,6 +9,7 @@ use Progress_Planner\Base; use Progress_Planner\Admin\Widgets\Activity_Scores; +use Progress_Planner\Utils\Security_Update_Monitor; /** * System_Status class. @@ -26,6 +27,17 @@ public function get_system_status() { // Get the number of pending updates. $data['pending_updates'] = \wp_get_update_data()['counts']['total']; + // Pending WordPress core security update, if any. + $security_update = Security_Update_Monitor::get_pending_security_update(); + $data['security_updates'] = [ + 'pending' => null !== $security_update, + 'installed_version' => null !== $security_update + ? $security_update['installed'] + : Security_Update_Monitor::get_effective_installed_version(), + 'offered_version' => null !== $security_update ? $security_update['offered'] : null, + 'last_alerted_version' => (string) \get_site_option( Security_Update_Monitor::ALERTED_VERSION_OPTION, '' ), + ]; + // Get number of content from any public post-type, published in the past week. $data['weekly_posts'] = \count( \get_posts( diff --git a/tests/phpunit/test-class-security-update-monitor.php b/tests/phpunit/test-class-security-update-monitor.php new file mode 100644 index 0000000000..3e410810e0 --- /dev/null +++ b/tests/phpunit/test-class-security-update-monitor.php @@ -0,0 +1,391 @@ + + */ + public function data_is_security_release() { + return [ + 'patch release' => [ '6.8.1', '6.8.2', true ], + 'first patch on a new branch' => [ '6.8', '6.8.1', true ], + 'patch jump of several versions' => [ '6.8.1', '6.8.4', true ], + 'new major.minor' => [ '6.7.2', '6.8.0', false ], + 'cross-branch jump' => [ '6.7.2', '6.8.2', false ], + 'same version' => [ '6.8.2', '6.8.2', false ], + 'downgrade' => [ '6.8.2', '6.8.1', false ], + 'installed alpha build' => [ '6.9-alpha-59000', '6.9.1', false ], + 'offered release candidate' => [ '6.8.1', '6.9-RC1', false ], + 'malformed offered version' => [ '6.8.1', 'not-a-version', false ], + 'empty installed version' => [ '', '6.8.2', false ], + ]; + } + + /** + * Test the is_security_release version heuristic. + * + * @dataProvider data_is_security_release + * + * @param string $installed The installed version. + * @param string $offered The offered version. + * @param bool $expected The expected result. + */ + public function test_is_security_release( $installed, $offered, $expected ) { + $this->assertSame( $expected, Security_Update_Monitor::is_security_release( $installed, $offered ) ); + } + + /** + * Test that get_pending_security_update picks the same-branch patch from a multi-offer transient. + */ + public function test_get_pending_security_update_picks_same_branch_patch() { + $result = Security_Update_Monitor::get_pending_security_update( + $this->get_mock_transient( [ '6.9.0', '6.8.2' ] ), + '6.8.1' + ); + + $this->assertSame( + [ + 'installed' => '6.8.1', + 'offered' => '6.8.2', + ], + $result + ); + } + + /** + * Test that get_pending_security_update returns the highest matching patch. + */ + public function test_get_pending_security_update_returns_highest_patch() { + $result = Security_Update_Monitor::get_pending_security_update( + $this->get_mock_transient( [ '6.8.2', '6.8.3' ] ), + '6.8.1' + ); + + $this->assertNotNull( $result ); + $this->assertSame( '6.8.3', $result['offered'] ); + } + + /** + * Test that a feature release does not register as a security update. + */ + public function test_get_pending_security_update_ignores_feature_release() { + $this->assertNull( + Security_Update_Monitor::get_pending_security_update( + $this->get_mock_transient( [ '6.9.0' ] ), + '6.8.2' + ) + ); + } + + /** + * Test the real API shape for a branch-behind site: the same-branch patch is + * only offered with response "autoupdate" once a newer major exists. + * + * A 6.9.1 site gets: upgrade 7.0.4, autoupdate 7.0.4, autoupdate 6.9.7. + */ + public function test_get_pending_security_update_detects_autoupdate_offer_on_older_branch() { + $transient = (object) [ + 'updates' => [ + (object) [ + 'response' => 'upgrade', + 'current' => '7.0.4', + ], + (object) [ + 'response' => 'autoupdate', + 'current' => '7.0.4', + ], + (object) [ + 'response' => 'autoupdate', + 'current' => '6.9.7', + ], + ], + ]; + + $this->assertSame( + [ + 'installed' => '6.9.1', + 'offered' => '6.9.7', + ], + Security_Update_Monitor::get_pending_security_update( $transient, '6.9.1' ) + ); + } + + /** + * Test that non-upgrade offers are ignored. + */ + public function test_get_pending_security_update_ignores_non_upgrade_offers() { + $transient = $this->get_mock_transient( [ '6.8.2' ] ); + + $transient->updates[0]->response = 'latest'; + + $this->assertNull( Security_Update_Monitor::get_pending_security_update( $transient, '6.8.1' ) ); + } + + /** + * Test that malformed transients are handled gracefully. + */ + public function test_get_pending_security_update_handles_malformed_transient() { + $this->assertNull( Security_Update_Monitor::get_pending_security_update( null, '6.8.1' ) ); + $this->assertNull( Security_Update_Monitor::get_pending_security_update( new \stdClass(), '6.8.1' ) ); + $this->assertNull( Security_Update_Monitor::get_pending_security_update( (object) [ 'updates' => 'nope' ], '6.8.1' ) ); + } + + /** + * Test that maybe_alert emails all update_core users exactly once per offered version. + */ + public function test_maybe_alert_sends_email_to_admins_once_per_version() { + $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + $editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); + $mails = $this->capture_mails(); + + $monitor = new Security_Update_Monitor(); + $monitor->maybe_alert( $this->get_security_transient() ); + + // On multisite only super admins can update core; on single site, all update_core users. + $expected_recipients = []; + if ( \is_multisite() ) { + foreach ( \get_super_admins() as $login ) { + $user = \get_user_by( 'login', $login ); + if ( $user ) { + $expected_recipients[] = $user->user_email; + } + } + } else { + foreach ( \get_users( [ 'capability' => 'update_core' ] ) as $user ) { + $expected_recipients[] = $user->user_email; + } + } + \sort( $expected_recipients ); + + $actual_recipients = \array_column( $mails->calls, 'to' ); + \sort( $actual_recipients ); + + $this->assertNotEmpty( $expected_recipients ); + $this->assertSame( $expected_recipients, $actual_recipients ); + $this->assertNotContains( \get_user_by( 'id', $editor_id )->user_email, $actual_recipients ); + if ( ! \is_multisite() ) { + $this->assertContains( \get_user_by( 'id', $admin_id )->user_email, $actual_recipients ); + } + + // Second call with the same offered version must not send again. + $mails->calls = []; + $monitor->maybe_alert( $this->get_security_transient() ); + $this->assertSame( [], $mails->calls ); + + // A newer offered version must alert again. + $monitor->maybe_alert( $this->get_security_transient( 2 ) ); + $this->assertNotEmpty( $mails->calls ); + } + + /** + * Test that the alert email names the offered version and links to the updates page. + */ + public function test_alert_email_contains_version_and_update_link() { + $mails = $this->capture_mails(); + + ( new Security_Update_Monitor() )->maybe_alert( $this->get_security_transient() ); + + $this->assertNotEmpty( $mails->calls ); + $mail = $mails->calls[0]; + $this->assertStringContainsString( $this->get_offered_version(), $mail['subject'] . $mail['message'] ); + $this->assertStringContainsString( \admin_url( 'update-core.php' ), $mail['message'] ); + // The backup advice must be present, but phrased not to delay the update. + $this->assertStringContainsString( 'backup', $mail['message'] ); + $this->assertStringContainsString( 'https://wordpress.org/documentation/article/wordpress-backups/', $mail['message'] ); + } + + /** + * Test that onboarded sites are sent to the Progress Planner dashboard, + * where the one-click branch-pinned update button lives. + */ + public function test_alert_email_links_to_dashboard_when_onboarded() { + \update_option( 'progress_planner_license_key', 'test-license-key' ); + $mails = $this->capture_mails(); + + ( new Security_Update_Monitor() )->maybe_alert( $this->get_security_transient() ); + + $this->assertNotEmpty( $mails->calls ); + $message = $mails->calls[0]['message']; + $this->assertStringContainsString( \admin_url( 'admin.php?page=progress-planner' ), $message ); + $this->assertStringContainsString( 'one-click', $message ); + $this->assertStringNotContainsString( \admin_url( 'update-core.php' ), $message ); + } + + /** + * Test that maybe_alert makes no HTTP requests (subscriber email is feed-driven, SaaS-side). + */ + public function test_maybe_alert_makes_no_http_requests() { + \update_option( 'progress_planner_license_key', 'test-license-key' ); + $this->capture_mails(); + $requests = $this->capture_http_requests(); + + ( new Security_Update_Monitor() )->maybe_alert( $this->get_security_transient() ); + + $this->assertSame( [], $requests->calls ); + } + + /** + * Test that maybe_alert ignores feature releases. + */ + public function test_maybe_alert_ignores_feature_releases() { + $mails = $this->capture_mails(); + + $parts = \explode( '.', \wp_get_wp_version() ); + $transient = $this->get_mock_transient( [ $parts[0] . '.' . ( (int) $parts[1] + 1 ) . '.0' ] ); + + ( new Security_Update_Monitor() )->maybe_alert( $transient ); + + $this->assertSame( [], $mails->calls ); + $this->assertFalse( \get_site_option( 'progress_planner_security_update_alerted_version' ) ); + } + + /** + * Test that the plugin bootstrap registers the monitor's detection hooks. + */ + public function test_monitor_hooks_are_registered_on_boot() { + $monitor = \progress_planner()->get_utils__security_update_monitor(); + + $this->assertInstanceOf( Security_Update_Monitor::class, $monitor ); + $this->assertNotFalse( \has_action( 'set_site_transient_update_core', [ $monitor, 'maybe_alert' ] ) ); + $this->assertNotFalse( \has_action( 'admin_init', [ $monitor, 'maybe_alert_from_stored_transient' ] ) ); + } + + /** + * Test that the system status exposes the pending security update to the SaaS. + */ + public function test_system_status_exposes_security_updates() { + \set_site_transient( 'update_core', $this->get_security_transient() ); + \update_site_option( 'progress_planner_security_update_alerted_version', $this->get_offered_version() ); + + $status = ( new \Progress_Planner\Utils\System_Status() )->get_system_status(); + + $this->assertArrayHasKey( 'security_updates', $status ); + $this->assertTrue( $status['security_updates']['pending'] ); + $this->assertSame( $this->get_offered_version(), $status['security_updates']['offered_version'] ); + $this->assertSame( $this->get_offered_version(), $status['security_updates']['last_alerted_version'] ); + $this->assertNotSame( '', $status['security_updates']['installed_version'] ); + } + + /** + * Test the system status without a pending security update. + */ + public function test_system_status_without_security_update() { + \delete_site_transient( 'update_core' ); + + $status = ( new \Progress_Planner\Utils\System_Status() )->get_system_status(); + + $this->assertFalse( $status['security_updates']['pending'] ); + $this->assertNull( $status['security_updates']['offered_version'] ); + } + + /** + * Capture outgoing mails via the pre_wp_mail short-circuit. + * + * @return object The recorder; captured calls in ->calls. + */ + private function capture_mails() { + $recorder = new \stdClass(); + $recorder->calls = []; + \add_filter( + 'pre_wp_mail', + static function ( $pre, $atts ) use ( $recorder ) { + $recorder->calls[] = $atts; + return true; + }, + 10, + 2 + ); + + return $recorder; + } + + /** + * Capture outgoing HTTP requests via the pre_http_request short-circuit. + * + * @return object The recorder; captured calls in ->calls. + */ + private function capture_http_requests() { + $recorder = new \stdClass(); + $recorder->calls = []; + \add_filter( + 'pre_http_request', + static function ( $pre, $args, $url ) use ( $recorder ) { + $recorder->calls[] = [ + 'url' => $url, + 'body' => isset( $args['body'] ) ? $args['body'] : [], + ]; + + return [ + 'headers' => [], + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + 'body' => (string) \wp_json_encode( [ 'status' => 'ok' ] ), + 'cookies' => [], + ]; + }, + 10, + 3 + ); + + return $recorder; + } + + /** + * Get an offered patch version relative to the running WordPress version. + * + * @param int $bump How many patch versions to bump. + * + * @return string + */ + private function get_offered_version( $bump = 1 ) { + $parts = \explode( '.', \wp_get_wp_version() ); + + return $parts[0] . '.' . $parts[1] . '.' . ( isset( $parts[2] ) ? (int) $parts[2] + $bump : $bump ); + } + + /** + * Build a transient offering a security release for the running WordPress version. + * + * @param int $bump How many patch versions to bump. + * + * @return \stdClass + */ + private function get_security_transient( $bump = 1 ) { + return $this->get_mock_transient( [ $this->get_offered_version( $bump ) ] ); + } + + /** + * Build a mock update_core transient object. + * + * @param string[] $offered_versions The offered versions. + * + * @return \stdClass + */ + private function get_mock_transient( $offered_versions ) { + $updates = []; + foreach ( $offered_versions as $version ) { + $updates[] = (object) [ + 'response' => 'upgrade', + 'current' => $version, + ]; + } + + return (object) [ 'updates' => $updates ]; + } +} diff --git a/tests/phpunit/test-class-security-update-provider.php b/tests/phpunit/test-class-security-update-provider.php new file mode 100644 index 0000000000..58a76d4bee --- /dev/null +++ b/tests/phpunit/test-class-security-update-provider.php @@ -0,0 +1,375 @@ +set_security_offer(); + $this->trait_set_up(); + + // The current user is reset between tests; capability checks need the admin. + \wp_set_current_user( 1 ); + } + + /** + * Complete the task. + * + * @return void + */ + protected function complete_task() { + // Simulate the update being installed: the refreshed transient reports the + // new version as installed and no longer offers an upgrade. + \set_site_transient( + 'update_core', + (object) [ + 'updates' => [], + 'version_checked' => $this->get_offered_version(), + ] + ); + } + + /** + * Test that the task ID carries the offered version. + */ + public function test_task_id_is_versioned() { + $expected = 'security-update-' . \str_replace( '.', '-', $this->get_offered_version() ); + $this->assertSame( $expected, $this->task_provider->get_task_id() ); + } + + /** + * Test that the task is neither dismissable nor snoozable and has top priority. + */ + public function test_task_is_locked_to_highest_urgency() { + $this->assertFalse( $this->task_provider->is_dismissable() ); + $this->assertFalse( $this->task_provider->is_snoozable() ); + $this->assertSame( 0, $this->task_provider->get_priority() ); + } + + /** + * Test that a superseded offer replaces the published task. + */ + public function test_superseded_offer_replaces_task() { + $this->task_provider->get_tasks_to_inject(); + $first_task_id = $this->task_provider->get_task_id(); + + // A newer patch release supersedes the first one. + $this->set_security_offer( 2 ); + $this->task_provider->get_tasks_to_inject(); + + $tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider' => $this->task_provider_id, + ] + ); + + $this->assertCount( 1, $tasks ); + $this->assertSame( $this->task_provider->get_task_id(), $tasks[0]->post_name ); + $this->assertNotSame( $first_task_id, $tasks[0]->post_name ); + } + + /** + * Test that a withdrawn offer deletes the published task without celebration. + */ + public function test_withdrawn_offer_deletes_task() { + $this->task_provider->get_tasks_to_inject(); + + // The offer is withdrawn; the installed version did not change. + \set_site_transient( 'update_core', (object) [ 'updates' => [] ] ); + + $completed = \progress_planner()->get_suggested_tasks()->get_tasks_manager()->evaluate_tasks(); + + $this->assertSame( [], $completed ); + $this->assertSame( + [], + (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider' => $this->task_provider_id, + ] + ) + ); + } + + /** + * Test that a new security release is injected even after an older one was completed. + */ + public function test_new_release_injected_after_previous_completion() { + $this->task_provider->get_tasks_to_inject(); + + // Complete the first task. + $tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider' => $this->task_provider_id, + ] + ); + \progress_planner()->get_suggested_tasks_db()->update_recommendation( $tasks[0]->ID, [ 'post_status' => 'trash' ] ); + + // A newer security release appears. + $this->set_security_offer( 2 ); + $this->task_provider->get_tasks_to_inject(); + + $tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider' => $this->task_provider_id, + ] + ); + + $this->assertCount( 1, $tasks ); + $this->assertSame( $this->task_provider->get_task_id(), $tasks[0]->post_name ); + } + + /** + * Test that a published security task hides all other tasks from the REST-format list. + */ + public function test_lockdown_limits_rest_format_to_security_task() { + $this->add_other_task(); + + // Without a published security task, the other task is listed. + $tasks = \progress_planner()->get_suggested_tasks()->get_tasks_in_rest_format( [ 'post_status' => 'publish' ] ); + $this->assertCount( 1, $tasks ); + + $this->task_provider->get_tasks_to_inject(); + \wp_cache_flush(); + + $tasks = \progress_planner()->get_suggested_tasks()->get_tasks_in_rest_format( [ 'post_status' => 'publish' ] ); + + $this->assertCount( 1, $tasks ); + $this->assertSame( $this->task_provider->get_task_id(), $tasks[0]['slug'] ); + } + + /** + * Test that the lockdown leaves non-publish statuses (celebrations, user tasks) alone. + */ + public function test_lockdown_does_not_affect_pending_celebration_tasks() { + // Celebration tasks transition publish → pending in production, keeping their slug. + $other_task_id = \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => 'core-blogdescription', + 'provider_id' => 'core-blogdescription', + 'post_title' => 'Celebrate me', + 'post_status' => 'publish', + 'url' => \admin_url( 'options-general.php' ), + ] + ); + \progress_planner()->get_suggested_tasks_db()->update_recommendation( $other_task_id, [ 'post_status' => 'pending' ] ); + $this->task_provider->get_tasks_to_inject(); + \wp_cache_flush(); + + $tasks = \progress_planner()->get_suggested_tasks()->get_tasks_in_rest_format( [ 'post_status' => 'pending' ] ); + + $this->assertCount( 1, $tasks ); + $this->assertSame( 'core-blogdescription', $tasks[0]['slug'] ); + } + + /** + * Test that the REST collection query is constrained to the security provider under lockdown. + */ + public function test_lockdown_forces_rest_tax_query_to_security_provider() { + $this->task_provider->get_tasks_to_inject(); + + $request = new \WP_REST_Request( 'GET', '/wp/v2/prpl_recommendations' ); + $args = \progress_planner()->get_suggested_tasks()->rest_api_tax_query( [], $request ); + + $this->assertSame( [ 'security-update' ], $this->get_tax_query_include_terms( $args ) ); + } + + /** + * Test that users who cannot install updates keep their normal task list. + */ + public function test_lockdown_skips_users_without_update_capability() { + $this->task_provider->get_tasks_to_inject(); + + $editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); + \wp_set_current_user( $editor_id ); + + $request = new \WP_REST_Request( 'GET', '/wp/v2/prpl_recommendations' ); + $args = \progress_planner()->get_suggested_tasks()->rest_api_tax_query( [], $request ); + $terms = $this->get_tax_query_include_terms( $args ); + + \wp_set_current_user( 1 ); + + $this->assertNotSame( [ 'security-update' ], $terms ); + $this->assertNotEmpty( $terms ); + } + + /** + * Test that automatic core updates complete the security task without celebration. + */ + public function test_automatic_update_completes_security_task() { + $this->task_provider->get_tasks_to_inject(); + + $tasks = (array) \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'post_status' => 'publish', + 'provider' => $this->task_provider_id, + ] + ); + $this->assertCount( 1, $tasks ); + + \progress_planner()->get_suggested_tasks()->on_automatic_updates_complete(); + + $this->assertSame( 'trash', \get_post_status( $tasks[0]->ID ) ); + } + + /** + * Test that the task description recommends a backup without inviting delay. + */ + public function test_task_description_recommends_backup() { + $description = $this->task_provider->get_task_details()['description']; + + $this->assertStringContainsString( 'backup', $description ); + $this->assertStringContainsString( 'postpone', $description ); + } + + /** + * Test that the task actions include a form that updates to the branch version directly. + * + * The wp-admin Updates page only shows the latest major (get_core_updates() skips + * "autoupdate" offers), so the task must offer the branch-pinned update itself. + */ + public function test_task_action_contains_branch_pinned_update_form() { + $actions = $this->task_provider->add_task_actions( [], [] ); + $html = \implode( ' ', \array_column( $actions, 'html' ) ); + + $this->assertStringContainsString( 'update-core.php?action=do-core-upgrade', $html ); + $this->assertStringContainsString( 'name="version" value="' . $this->get_offered_version() . '"', $html ); + $this->assertStringContainsString( 'name="locale" value="en_US"', $html ); + $this->assertStringContainsString( 'name="upgrade"', $html ); + $this->assertStringContainsString( 'name="_wpnonce"', $html ); + // The fallback link to the Updates page remains. + $this->assertStringContainsString( 'Go to the Updates page', $html ); + } + + /** + * Test that the update form uses the locale of the matched offer. + */ + public function test_task_action_form_uses_offer_locale() { + \set_site_transient( + 'update_core', + (object) [ + 'updates' => [ + (object) [ + 'response' => 'autoupdate', + 'current' => $this->get_offered_version(), + 'locale' => 'de_DE', + ], + ], + ] + ); + + $actions = $this->task_provider->add_task_actions( [], [] ); + $html = \implode( ' ', \array_column( $actions, 'html' ) ); + + $this->assertStringContainsString( 'name="locale" value="de_DE"', $html ); + } + + /** + * Test that no update form is rendered without a pending security update. + */ + public function test_task_action_form_absent_without_pending_update() { + \set_site_transient( 'update_core', (object) [ 'updates' => [] ] ); + + $actions = $this->task_provider->add_task_actions( [], [] ); + $html = \implode( ' ', \array_column( $actions, 'html' ) ); + + $this->assertStringNotContainsString( 'do-core-upgrade', $html ); + $this->assertStringContainsString( 'Go to the Updates page', $html ); + } + + /** + * Add a published task from another provider. + * + * @return void + */ + private function add_other_task() { + \progress_planner()->get_suggested_tasks_db()->add( + [ + 'task_id' => 'core-blogdescription', + 'provider_id' => 'core-blogdescription', + 'post_title' => 'Set your tagline', + 'post_status' => 'publish', + 'url' => \admin_url( 'options-general.php' ), + ] + ); + \wp_cache_flush(); + } + + /** + * Extract the IN-operator provider terms from REST query args. + * + * @param array $args The query args returned by rest_api_tax_query(). + * + * @return array + */ + private function get_tax_query_include_terms( $args ) { + foreach ( (array) ( $args['tax_query'] ?? [] ) as $clause ) { + if ( \is_array( $clause ) && isset( $clause['operator'] ) && 'IN' === $clause['operator'] ) { + return \array_values( (array) $clause['terms'] ); + } + } + + return []; + } + + /** + * Get an offered patch version relative to the running WordPress version. + * + * @param int $bump How many patch versions to bump. + * + * @return string + */ + private function get_offered_version( $bump = 1 ) { + $parts = \explode( '.', \wp_get_wp_version() ); + + return $parts[0] . '.' . $parts[1] . '.' . ( isset( $parts[2] ) ? (int) $parts[2] + $bump : $bump ); + } + + /** + * Store an update_core transient offering a security release. + * + * @param int $bump How many patch versions to bump. + * + * @return void + */ + private function set_security_offer( $bump = 1 ) { + \set_site_transient( + 'update_core', + (object) [ + 'updates' => [ + (object) [ + 'response' => 'upgrade', + 'current' => $this->get_offered_version( $bump ), + ], + ], + ] + ); + } +} diff --git a/views/page-widgets/suggested-tasks.php b/views/page-widgets/suggested-tasks.php index d0ca25a330..64099f4d2e 100644 --- a/views/page-widgets/suggested-tasks.php +++ b/views/page-widgets/suggested-tasks.php @@ -23,15 +23,21 @@ ); ?> -

- get_ui__branding()->get_ravi_name() ) - ); - ?> -

+ get_utils__security_update_monitor()->is_security_lockdown_active() && \current_user_can( 'update_core' ) ) : ?> +

+ +

+ +

+ get_ui__branding()->get_ravi_name() ) + ); + ?> +

+