diff --git a/src/js/_enqueues/admin/site-health.js b/src/js/_enqueues/admin/site-health.js index 57d5c9cbcf289..7012f5b848f9c 100644 --- a/src/js/_enqueues/admin/site-health.js +++ b/src/js/_enqueues/admin/site-health.js @@ -156,10 +156,14 @@ jQuery( function( $ ) { count = SiteHealth.site_status.issues[ issue.status ]; - // If no test name is supplied, append a placeholder for markup references. - if ( typeof issue.test === 'undefined' ) { - issue.test = issue.status + count; - } + /* + * Collect the test name and status so they can be cached server-side. These + * include the asynchronous tests that only run in the browser. Labels are left + * out on purpose, as they are translated and the cache is shared across locales. + */ + SiteHealth.site_status.results[ issue.test ] = { + status: issue.status + }; if ( 'critical' === issue.status ) { heading = sprintf( @@ -250,6 +254,7 @@ jQuery( function( $ ) { } if ( isStatusTab ) { + // Refresh the lightweight counts first, so a large detailed payload can't block them. $.post( ajaxurl, { @@ -259,6 +264,18 @@ jQuery( function( $ ) { } ); + // Send the per-test results separately, as a best-effort detailed cache update. + if ( Object.keys( SiteHealth.site_status.results ).length > 0 ) { + $.post( + ajaxurl, + { + 'action': 'health-check-site-status-result', + '_wpnonce': SiteHealth.nonce.site_status_result, + 'results': JSON.stringify( SiteHealth.site_status.results ) + } + ); + } + if ( 100 === val ) { $( '.site-status-all-clear' ).removeClass( 'hide' ); $( '.site-status-has-issues' ).addClass( 'hide' ); diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 2af08fba70af9..11e663f763b44 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -5457,7 +5457,13 @@ function wp_ajax_health_check_loopback_requests() { /** * Handles site health check to update the result status via AJAX. * + * The aggregate counts and the full per-test results are sent as two independent + * requests, so a large results payload cannot prevent the lightweight counts from being + * refreshed. Each is handled on its own here, and either may be omitted. + * * @since 5.2.0 + * @since 7.1.0 The submitted counts are validated, and the optional full per-test results + * are sanitized and cached separately as the authoritative detailed results. */ function wp_ajax_health_check_site_status_result() { check_ajax_referer( 'health-check-site-status-result' ); @@ -5466,7 +5472,40 @@ function wp_ajax_health_check_site_status_result() { wp_send_json_error(); } - set_transient( 'health-check-site-status-result', wp_json_encode( $_POST['counts'] ) ); + if ( ! class_exists( 'WP_Site_Health' ) ) { + require_once ABSPATH . 'wp-admin/includes/class-wp-site-health.php'; + } + + $save_results = array(); + + // Refresh the lightweight, autoloaded aggregate counts used by the admin menu and Dashboard. + // TODO: We don't need to keep this anymore. We can just obtain the counts at runtime from the stored results. + if ( isset( $_POST['counts'] ) && is_array( $_POST['counts'] ) ) { + $counts = wp_unslash( $_POST['counts'] ); + $save_results[] = WP_Site_Health::set_site_status_counts( $counts ); + } + + /* + * Cache the full per-test results as the authoritative detailed results. These include + * the asynchronous tests that require JavaScript to run. The values are sanitized in + * WP_Site_Health::update_site_status_detail(). + */ + if ( isset( $_POST['results'] ) && is_string( $_POST['results'] ) ) { + $results = json_decode( wp_unslash( $_POST['results'] ), true ); + + if ( is_array( $results ) ) { + $save_results[] = WP_Site_Health::update_site_status_detail( $results ); + } + } + + if ( count( $save_results ) === 0 ) { + wp_send_json_error(); + } + + $errors = array_filter( $save_results, 'is_wp_error' ); + if ( count( $errors ) > 0 ) { + wp_send_json_error( reset( $errors ) ); + } wp_send_json_success(); } diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 9eb4c8525a942..df6e641edb154 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -7,6 +7,26 @@ * @since 5.2.0 */ +/** + * @phpstan-type Test_Status_Counts array{ + * good: non-negative-int, + * recommended: non-negative-int, + * critical: non-negative-int, + * } + * @phpstan-type Stored_Test_Result array{ + * status: 'good'|'recommended'|'critical', + * timestamp: non-negative-int, + * } + * @phpstan-type Stored_Test_Results array{ + * results: array, + * timestamp: non-negative-int, + * } + * @phpstan-type Site_Status_Detail array{ + * results: array, + * counts: Test_Status_Counts, + * timestamp: non-negative-int, + * } + */ #[AllowDynamicProperties] class WP_Site_Health { private static $instance = null; @@ -29,6 +49,75 @@ class WP_Site_Health { private $timeout_missed_cron = null; private $timeout_late_cron = null; + private const VALID_STATUS_VALUES = array( 'good', 'recommended', 'critical' ); + + private const STATUS_COUNTS_SCHEMA = array( + 'type' => 'object', + 'properties' => array( + 'good' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + 'recommended' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + 'critical' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + ), + ); + + private const STORED_STATUS_SCHEMA = array( + 'type' => 'object', + 'properties' => array( + 'results' => array( + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'enum' => self::VALID_STATUS_VALUES, + ), + 'timestamp' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + ), + 'required' => array( 'status', 'timestamp' ), + 'additionalProperties' => false, + ), + ), + 'timestamp' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + ), + 'required' => array( 'results', 'timestamp' ), + ); + + /** + * Transient name for the cached aggregate Site Health status counts. + * + * This value is small and read on the admin menu and Dashboard, so it remains + * autoloaded. + * + * @since 7.1.0 + */ + const STATUS_RESULT_TRANSIENT = 'health-check-site-status-result'; + + /** + * Transient name for the cached detailed Site Health test results. + * + * This value can be large, so it is stored with an expiration to keep it from + * being autoloaded on every request. + * + * @since 7.1.0 + */ + const STATUS_DETAIL_TRANSIENT = 'health-check-site-status-detail'; + /** * WP_Site_Health constructor. * @@ -74,7 +163,7 @@ public function show_site_health_tab( $tab ) { * * @since 5.4.0 * - * @return WP_Site_Health|null + * @return WP_Site_Health */ public static function get_instance() { if ( null === self::$instance ) { @@ -102,23 +191,18 @@ public function enqueue_scripts() { 'site_status_result' => wp_create_nonce( 'health-check-site-status-result' ), ), 'site_status' => array( - 'direct' => array(), - 'async' => array(), - 'issues' => array( + 'direct' => array(), + 'async' => array(), + 'issues' => array( 'good' => 0, 'recommended' => 0, 'critical' => 0, ), + 'results' => new stdClass(), ), ); - $issue_counts = get_transient( 'health-check-site-status-result' ); - - if ( false !== $issue_counts ) { - $issue_counts = json_decode( $issue_counts ); - - $health_check_js_variables['site_status']['issues'] = $issue_counts; - } + $health_check_js_variables['site_status']['issues'] = self::get_site_status_counts(); if ( 'site-health' === $screen->id && ( ! isset( $_GET['tab'] ) || empty( $_GET['tab'] ) ) ) { $tests = WP_Site_Health::get_tests(); @@ -2848,6 +2932,22 @@ public function get_test_opcode_cache(): array { * @since 5.6.0 Added support for `has_rest` and `permissions`. * * @return array The list of tests to run. + * + * @phpstan-return array{ + * direct: array, + * async: array, + * }>, + * } */ public static function get_tests() { $tests = array( @@ -3349,6 +3449,8 @@ public function maybe_create_scheduled_event() { * Runs the scheduled event to check and update the latest site health status for the website. * * @since 5.4.0 + * @since 7.1.0 The full test results are also cached in a separate detailed + * transient via WP_Site_Health::update_site_status_detail(). */ public function wp_cron_scheduled_check() { // Bootstrap wp-admin, as WP_Cron doesn't do this for us. @@ -3356,13 +3458,8 @@ public function wp_cron_scheduled_check() { $tests = WP_Site_Health::get_tests(); - $results = array(); - - $site_status = array( - 'good' => 0, - 'recommended' => 0, - 'critical' => 0, - ); + $results = array(); + $unavailable_tests = array(); // Don't run https test on development environments. if ( $this->is_development_environment() ) { @@ -3391,7 +3488,7 @@ public function wp_cron_scheduled_check() { } } - foreach ( $tests['async'] as $test ) { + foreach ( $tests['async'] as $test_name => $test ) { if ( ! empty( $test['skip_cron'] ) ) { continue; } @@ -3435,7 +3532,10 @@ public function wp_cron_scheduled_check() { if ( is_array( $result ) ) { $results[] = $result; } else { - $results[] = array( + // Include the test identifier so the unavailable result is counted consistently. + $unavailable_tests[ $test_name ] = true; + $results[] = array( + 'test' => $test_name, 'status' => 'recommended', 'label' => __( 'A test is unavailable' ), ); @@ -3443,17 +3543,251 @@ public function wp_cron_scheduled_check() { } } + $results_to_count = array(); + $results_to_store = array(); foreach ( $results as $result ) { - if ( 'critical' === $result['status'] ) { - ++$site_status['critical']; - } elseif ( 'recommended' === $result['status'] ) { - ++$site_status['recommended']; + // Filters may return unexpected values, so require the fields used by both caches. + if ( ! is_array( $result ) ) { + continue; + } + + $test = $result['test'] ?? null; + $status = $result['status'] ?? null; + if ( + ! is_string( $test ) + || '' === sanitize_text_field( $test ) + || ! in_array( $status, self::VALID_STATUS_VALUES, true ) + ) { + continue; + } + + $results_to_count[] = array( 'status' => $status ); + if ( ! isset( $unavailable_tests[ $test ] ) ) { + $results_to_store[ $test ] = array( 'status' => $status ); + } + } + + self::set_site_status_counts( self::count_site_status_results( $results_to_count ) ); + + /* + * Cache the full results separately, keyed by test, so consumers can read the + * detailed Site Health status without re-running the tests. + * + * Only verified results are included. An unavailable asynchronous test is still + * reflected in the aggregate counts, but does not replace a previously verified + * detailed result. + */ + self::update_site_status_detail( $results_to_store ); + } + + /** + * Returns the cached aggregate Site Health status counts. + * + * @since 7.1.0 + * + * @return Test_Status_Counts Aggregate counts. Each value is `0` when no result has been cached. + */ + public static function get_site_status_counts(): array { + $counts = get_transient( self::STATUS_RESULT_TRANSIENT ); + if ( is_string( $counts ) ) { + $counts = json_decode( $counts, true ); + } + + if ( ! is_array( $counts ) || is_wp_error( rest_validate_value_from_schema( $counts, self::STATUS_COUNTS_SCHEMA ) ) ) { + $counts = array(); + } + + return self::normalize_status_counts( $counts ); + } + + /** + * Caches the aggregate Site Health status counts. + * + * @since 7.1.0 + * + * @param mixed[] $counts Aggregate counts. + * @return true|WP_Error True when the counts were saved, or WP_Error on failure. + */ + public static function set_site_status_counts( array $counts ) { + $validity = rest_validate_value_from_schema( $counts, self::STATUS_COUNTS_SCHEMA ); + if ( is_wp_error( $validity ) ) { + return $validity; + } + + $stored_value = wp_json_encode( $counts ); + if ( get_transient( self::STATUS_RESULT_TRANSIENT ) === $stored_value ) { + return true; + } + + if ( ! set_transient( self::STATUS_RESULT_TRANSIENT, wp_json_encode( $counts ) ) ) { + return new WP_Error( 'set_transient_error', __( 'The Site Health status counts could not be saved.' ) ); + } + + return true; + } + + /** + * Returns the cached detailed Site Health test results. + * + * The `counts` are derived from the cached `results`, so the two are always + * internally consistent. They may differ from WP_Site_Health::get_site_status_counts(), + * which reflects the latest run for the admin menu and Dashboard. Consumers that need a + * consistent view of counts and detailed results should read both from here. + * + * @since 7.1.0 + * @todo This method is now not used. + * + * @return array The cached results as a map keyed by test name, aggregate counts derived + * from those same results, and the time of the most recent update. `results` + * is empty, all counts are `0`, and `timestamp` is `0` when none are cached. + * @phpstan-return Site_Status_Detail + */ + public static function get_site_status_detail(): array { + $cached = self::read_site_status_detail_cache(); + + return array( + 'results' => $cached['results'], + 'counts' => self::count_site_status_results( array_values( $cached['results'] ) ), + 'timestamp' => $cached['timestamp'], + ); + } + + /** + * Updates the detailed Site Health results cache. + * + * Results are normalized and sanitized, then stored keyed by test name with a + * per-result timestamp. Entries that have not been refreshed within a month are + * dropped, for example when the plugin that registered a test has been deactivated. + * + * @since 7.1.0 + * + * @param array $results Map of raw Site Health test result arrays, + * keyed by test name. + * @return true|WP_Error True on success, WP_Error on failure. + */ + public static function update_site_status_detail( array $results ) { + $now = time(); + + $cached = self::read_site_status_detail_cache(); + + foreach ( $results as $test => $result ) { + $test = sanitize_text_field( $test ); + if ( '' === $test ) { + return new WP_Error( 'invalid_test', __( 'The Site Health test identifier is invalid.' ) ); + } + + if ( is_array( $result ) ) { + $result['timestamp'] = $now; + } + + $validity = rest_validate_value_from_schema( $result, self::STORED_STATUS_SCHEMA['properties']['results']['additionalProperties'] ); + if ( is_wp_error( $validity ) ) { + return $validity; + } + /** @var Stored_Test_Result $result */ + + $cached['results'][ $test ] = $result; + } + + // Drop results that have not been refreshed within the last month. + foreach ( $cached['results'] as $test => $result ) { + if ( ! isset( $result['timestamp'] ) || ( $now - (int) $result['timestamp'] ) > MONTH_IN_SECONDS ) { + unset( $cached['results'][ $test ] ); + } + } + + $stored_value = wp_json_encode( + array( + 'results' => $cached['results'], + 'timestamp' => $now, + ) + ); + if ( get_transient( self::STATUS_DETAIL_TRANSIENT ) === $stored_value ) { + return true; + } + + if ( ! set_transient( self::STATUS_DETAIL_TRANSIENT, $stored_value, MONTH_IN_SECONDS ) ) { + return new WP_Error( 'set_transient_error', __( 'The detailed Site Health status could not be saved.' ) ); + } + return true; + } + + /** + * Reads and decodes the cached detailed Site Health test results. + * + * @since 7.1.0 + * + * @return array The stored status. + * @phpstan-return Stored_Test_Results + */ + private static function read_site_status_detail_cache(): array { + $cached = get_transient( self::STATUS_DETAIL_TRANSIENT ); + if ( is_string( $cached ) ) { + $cached = json_decode( $cached, true ); + } + + $validity = false; + if ( is_array( $cached ) ) { + $validity = rest_validate_value_from_schema( $cached, self::STORED_STATUS_SCHEMA ); + } + + if ( true !== $validity ) { + $cached = array( + 'results' => array(), + 'timestamp' => 0, + ); + } + + /** @var Stored_Test_Results $cached */ + return $cached; + } + + /** + * Normalizes a set of aggregate status counts to integers. + * + * @since 7.1.0 + * + * @param mixed[] $counts Raw counts that may be missing keys or hold non-integer values. + * @return array The good, recommended, and critical counts. + * @phpstan-return Test_Status_Counts + */ + private static function normalize_status_counts( array $counts ): array { + return array( + 'good' => max( 0, (int) ( $counts['good'] ?? 0 ) ), + 'recommended' => max( 0, (int) ( $counts['recommended'] ?? 0 ) ), + 'critical' => max( 0, (int) ( $counts['critical'] ?? 0 ) ), + ); + } + + /** + * Counts a set of Site Health results by status. + * + * @since 7.1.0 + * + * @param list $results List of result arrays, each with a status. + * @return array Aggregate counts, one bucket per status. A status other than `recommended` or `critical` counts as `good`. + * @phpstan-return Test_Status_Counts + */ + private static function count_site_status_results( array $results ): array { + $counts = array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ); + + foreach ( $results as $result ) { + $status = $result['status']; + + if ( 'critical' === $status ) { + ++$counts['critical']; + } elseif ( 'recommended' === $status ) { + ++$counts['recommended']; } else { - ++$site_status['good']; + ++$counts['good']; } } - set_transient( 'health-check-site-status-result', wp_json_encode( $site_status ) ); + return $counts; } /** diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 6080b477f54c3..46990f261b97f 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -707,4 +707,502 @@ public function test_get_test_opcode_cache_result_by_environment() { $this->assertStringContainsString( __( 'Enabling this cache can significantly improve the performance of your site.' ), $result['description'] ); } } + + /** + * Registers a controlled set of direct Site Health tests via `site_status_tests`. + * + * @param array $direct Map of result arrays to return, keyed by test name. + * @return Closure The filter callback, so the caller can remove it. + */ + private function use_fake_site_status_tests( array $direct ): Closure { + $tests = array( + 'direct' => array(), + 'async' => array(), + ); + + foreach ( $direct as $name => $result ) { + $tests['direct'][ $name ] = array( + 'label' => $name, + 'test' => static function () use ( $result ) { + return $result; + }, + ); + } + + $filter = static function () use ( $tests ) { + return $tests; + }; + + add_filter( 'site_status_tests', $filter ); + + return $filter; + } + + /** + * Seeds the detailed Site Health results cache. + * + * @param array $results Map of result arrays keyed by test name. + * @param int $time Timestamp to store for the cache and each result. + */ + private function seed_site_status_detail( array $results, int $time ): void { + foreach ( $results as $test => $result ) { + $results[ $test ]['timestamp'] = $time; + } + + set_transient( + WP_Site_Health::STATUS_DETAIL_TRANSIENT, + wp_json_encode( + array( + 'results' => $results, + 'timestamp' => $time, + ) + ), + MONTH_IN_SECONDS + ); + } + + /** + * The scheduled check stores only the aggregate counts in the autoloaded transient + * and caches the per-test results separately. + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + * @covers ::get_site_status_detail + * @covers ::set_site_status_counts + * @covers ::update_site_status_detail + */ + public function test_scheduled_check_stores_counts_and_detail_separately(): void { + $filter = $this->use_fake_site_status_tests( + array( + 'fake_critical' => array( + 'test' => 'fake_critical', + 'label' => 'Critical label', + 'status' => 'critical', + 'badge' => array( + 'label' => 'Security', + 'color' => 'red', + ), + 'description' => '

Critical description.

', + 'actions' => '', + ), + 'fake_recommended' => array( + 'test' => 'fake_recommended', + 'label' => 'Recommended label', + 'status' => 'recommended', + 'description' => '

Recommended description.

', + ), + 'fake_good' => array( + 'test' => 'fake_good', + 'label' => 'Good label', + 'status' => 'good', + 'description' => '

Good description.

', + ), + ) + ); + + delete_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + $counts_reads_before = did_filter( 'pre_transient_' . WP_Site_Health::STATUS_RESULT_TRANSIENT ); + $detail_reads_before = did_filter( 'pre_transient_' . WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + $before = time(); + $this->instance->wp_cron_scheduled_check(); + $after = time(); + + remove_filter( 'site_status_tests', $filter ); + + $this->assertSame( + 1, + did_filter( 'pre_transient_' . WP_Site_Health::STATUS_RESULT_TRANSIENT ) - $counts_reads_before, + 'The counts transient should be read once during the scheduled check.' + ); + $this->assertSame( + 2, + did_filter( 'pre_transient_' . WP_Site_Health::STATUS_DETAIL_TRANSIENT ) - $detail_reads_before, + 'The detail transient should be read twice during the scheduled check.' + ); + + // The counts transient holds only the aggregate counts, so it stays small enough to autoload. + $counts_transient = get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + $this->assertIsString( $counts_transient ); + $stored_counts = json_decode( $counts_transient, true ); + $this->assertIsArray( $stored_counts ); + $this->assertSame( + array( + 'good' => 1, + 'recommended' => 1, + 'critical' => 1, + ), + $stored_counts, + 'The counts transient should contain only the aggregate counts.' + ); + + // The detail transient stores only results and a timestamp; counts are derived when it is read. + $detail_transient = get_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + $this->assertIsString( $detail_transient ); + $stored_detail = json_decode( $detail_transient, true ); + $this->assertIsArray( $stored_detail ); + $this->assertSameSets( + array( 'results', 'timestamp' ), + array_keys( $stored_detail ), + 'The detail transient should contain only the results and timestamp.' + ); + + // The detailed cache holds every result, including the passing one. + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertArrayHasKey( 'results', $detail ); + $this->assertCount( 3, $detail['results'], 'All results should be cached, not just actionable ones.' ); + + $results_by_test = $detail['results']; + + $this->assertSame( + array( 'fake_critical', 'fake_recommended', 'fake_good' ), + array_keys( $results_by_test ) + ); + + // Only locale-independent fields are stored: the test name, status, and timestamp. + $this->assertSameSets( + array( 'status', 'timestamp' ), + array_keys( $results_by_test['fake_critical'] ), + 'No translated or HTML fields should be cached.' + ); + $this->assertSame( 'critical', $results_by_test['fake_critical']['status'] ); + + // Each result carries its own collection timestamp. + $this->assertGreaterThanOrEqual( $before, $results_by_test['fake_critical']['timestamp'] ); + $this->assertLessThanOrEqual( $after, $results_by_test['fake_critical']['timestamp'] ); + + // The detail cache exposes counts derived from its own results. + $this->assertSame( + array( + 'good' => 1, + 'recommended' => 1, + 'critical' => 1, + ), + $detail['counts'], + 'Detail counts should be derived from the cached detail results.' + ); + } + + /** + * Only valid results are cached. The scheduled check ignores invalid results + * returned by the result filter, while direct updates return a validation error. + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + * @covers ::update_site_status_detail + */ + public function test_only_valid_results_are_cached(): void { + $invalid_results = array( + 'non_array_result' => new WP_Error( 'invalid_result' ), + 'missing_test' => array( 'status' => 'good' ), + 'empty_test' => array( + 'test' => '', + 'status' => 'good', + ), + 'invalid_test' => array( + 'test' => array(), + 'status' => 'good', + ), + 'missing_status' => array( 'test' => 'missing_status' ), + 'invalid_status' => array( + 'test' => 'invalid_status', + 'status' => 'invalid', + ), + ); + $test_results = array(); + foreach ( array_merge( array_keys( $invalid_results ), array( 'valid_result' ) ) as $test ) { + $test_results[ $test ] = array( + 'test' => $test, + 'status' => 'good', + ); + } + + $tests_filter = $this->use_fake_site_status_tests( $test_results ); + $result_filter = static function ( $result ) use ( $invalid_results ) { + if ( is_array( $result ) && isset( $invalid_results[ $result['test'] ] ) ) { + return $invalid_results[ $result['test'] ]; + } + + return $result; + }; + add_filter( 'site_status_test_result', $result_filter ); + + delete_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + try { + $this->instance->wp_cron_scheduled_check(); + } finally { + remove_filter( 'site_status_test_result', $result_filter ); + remove_filter( 'site_status_tests', $tests_filter ); + } + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertSame( array( 'valid_result' ), array_keys( $detail['results'] ) ); + $this->assertSame( + array( + 'good' => 1, + 'recommended' => 0, + 'critical' => 0, + ), + $detail['counts'] + ); + $this->assertSame( $detail['counts'], WP_Site_Health::get_site_status_counts() ); + + $result = WP_Site_Health::update_site_status_detail( + array( + 'invalid_status' => array( + 'status' => 'invalid', + ), + ) + ); + + $this->assertWPError( $result ); + + $result = WP_Site_Health::update_site_status_detail( + array( + '' => array( + 'status' => 'good', + ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'invalid_test', $result->get_error_code() ); + $this->assertNotSame( '', $result->get_error_message() ); + $this->assertSame( array( 'valid_result' ), array_keys( WP_Site_Health::get_site_status_detail()['results'] ) ); + } + + /** + * An unavailable asynchronous test is counted but does not replace a previously + * verified detailed result. + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + */ + public function test_scheduled_check_preserves_verified_result_when_async_test_is_unavailable(): void { + $cached_at = time() - 2 * WEEK_IN_SECONDS; + $this->seed_site_status_detail( + array( + 'my_async' => array( + 'status' => 'good', + ), + ), + $cached_at + ); + + // Force the asynchronous remote request to fail so the fallback path runs. + $http = static function () { + return new WP_Error( 'unavailable', 'Service unavailable.' ); + }; + add_filter( 'pre_http_request', $http ); + + $filter = static function () { + return array( + 'direct' => array(), + 'async' => array( + 'my_async' => array( + 'label' => 'My async test', + 'test' => 'https://example.invalid/site-health', + 'has_rest' => true, + ), + ), + ); + }; + add_filter( 'site_status_tests', $filter ); + + $this->instance->wp_cron_scheduled_check(); + + remove_filter( 'site_status_tests', $filter ); + remove_filter( 'pre_http_request', $http ); + + $detail = WP_Site_Health::get_site_status_detail(); + $results_by_id = $detail['results']; + + $this->assertArrayHasKey( 'my_async', $results_by_id ); + $this->assertSame( 'good', $results_by_id['my_async']['status'] ); + $this->assertSame( $cached_at, $results_by_id['my_async']['timestamp'] ); + $this->assertSame( 1, WP_Site_Health::get_site_status_counts()['recommended'] ); + } + + /** + * The scheduled check overwrites an existing result when it collects a newer verified result. + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + * @covers ::update_site_status_detail + */ + public function test_scheduled_check_stores_freshest_verified_result(): void { + $this->seed_site_status_detail( + array( + 'fake_async' => array( + 'status' => 'good', + 'timestamp' => time(), + ), + ), + time() + ); + + $filter = $this->use_fake_site_status_tests( + array( + 'fake_async' => array( + 'test' => 'fake_async', + 'status' => 'critical', + ), + ) + ); + + $this->instance->wp_cron_scheduled_check(); + + remove_filter( 'site_status_tests', $filter ); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertCount( 1, $detail['results'] ); + $this->assertSame( 'critical', array_first( $detail['results'] )['status'] ); + } + + /** + * Results that have not been refreshed within a month are dropped. + * + * @ticket 65232 + * + * @covers ::update_site_status_detail + */ + public function test_update_site_status_detail_drops_stale_entries(): void { + $now = time(); + + set_transient( + WP_Site_Health::STATUS_DETAIL_TRANSIENT, + wp_json_encode( + array( + 'results' => array( + 'recent' => array( + 'status' => 'good', + 'timestamp' => $now, + ), + 'old' => array( + 'status' => 'good', + 'timestamp' => $now - 2 * MONTH_IN_SECONDS, + ), + ), + 'timestamp' => $now, + ) + ), + MONTH_IN_SECONDS + ); + + // Update with nothing new; the stale-entry pruning still runs. + WP_Site_Health::update_site_status_detail( array() ); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertCount( 1, $detail['results'] ); + $this->assertArrayHasKey( 'recent', $detail['results'] ); + } + + /** + * The counts accessor returns zeroed counts when nothing is cached. + * + * @ticket 65232 + * + * @covers ::get_site_status_counts + */ + public function test_get_site_status_counts_defaults_when_uncached(): void { + delete_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + + $this->assertSame( + array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ), + WP_Site_Health::get_site_status_counts() + ); + } + + /** + * The counts accessor returns the cached aggregate counts. + * + * @ticket 65232 + * + * @covers ::get_site_status_counts + */ + public function test_get_site_status_counts_returns_cached_counts(): void { + $counts = array( + 'good' => 3, + 'recommended' => 2, + 'critical' => 1, + ); + set_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT, wp_json_encode( $counts ) ); + + $this->assertSame( $counts, WP_Site_Health::get_site_status_counts() ); + } + + /** + * The detail accessor distinguishes a valid empty cache from a missing or malformed cache. + * + * @ticket 65232 + * + * @covers ::get_site_status_detail + * @covers ::update_site_status_detail + */ + public function test_get_site_status_detail_distinguishes_empty_from_missing_or_malformed_cache(): void { + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertSame( array(), $detail['results'] ); + $this->assertSame( 0, $detail['timestamp'] ); + + $before = time(); + $this->assertTrue( WP_Site_Health::update_site_status_detail( array() ) ); + $after = time(); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertSame( array(), $detail['results'] ); + $this->assertGreaterThanOrEqual( $before, $detail['timestamp'] ); + $this->assertLessThanOrEqual( $after, $detail['timestamp'] ); + $this->assertIsString( get_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ) ); + + $now = time(); + set_transient( + WP_Site_Health::STATUS_DETAIL_TRANSIENT, + wp_json_encode( + array( + 'results' => array( + 'missing_timestamp' => array( 'status' => 'good' ), + ), + 'timestamp' => $now, + ) + ), + MONTH_IN_SECONDS + ); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertSame( array(), $detail['results'] ); + $this->assertSame( 0, $detail['timestamp'] ); + + set_transient( + WP_Site_Health::STATUS_DETAIL_TRANSIENT, + wp_json_encode( + array( + 'results' => array( + 'missing_cache_timestamp' => array( + 'status' => 'good', + 'timestamp' => $now, + ), + ), + ), + ), + MONTH_IN_SECONDS + ); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertSame( array(), $detail['results'] ); + $this->assertSame( 0, $detail['timestamp'] ); + } } diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php new file mode 100644 index 0000000000000..a64520decb187 --- /dev/null +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -0,0 +1,325 @@ +super_admin_user_id ) { + revoke_super_admin( $this->super_admin_user_id ); + $this->super_admin_user_id = 0; + } + + parent::tear_down(); + } + + /** + * Sets the current user to one that can view Site Health checks. + */ + private function set_user_with_site_health_capability(): void { + $this->_setRole( 'administrator' ); + + if ( is_multisite() ) { + $this->super_admin_user_id = get_current_user_id(); + grant_super_admin( $this->super_admin_user_id ); + } + } + + /** + * Dispatches the Ajax request and returns the decoded JSON response. + * + * @return array{ success: bool, ... } The decoded response. + */ + private function dispatch_result_request(): array { + $this->_last_response = ''; + + try { + $this->_handleAjax( self::ACTION ); + } catch ( WPAjaxDieContinueException $e ) { + unset( $e ); + } + + $response = json_decode( $this->_last_response, true ); + assert( is_array( $response ) ); + return $response; + } + + /** + * The aggregate counts are cached on their own, while the results submitted by + * the Site Health screen are sanitized and cached separately. + * + * @ticket 65232 + */ + public function test_posting_counts_and_results_caches_them_separately(): void { + delete_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + $this->set_user_with_site_health_capability(); + $_POST['_wpnonce'] = wp_create_nonce( self::ACTION ); + $_POST['counts'] = array( + 'good' => 5, + 'recommended' => 1, + 'critical' => 2, + ); + $_POST['results'] = wp_slash( + wp_json_encode( + array( + 'a' => array( + 'status' => 'critical', + ), + 'b' => array( + 'status' => 'recommended', + ), + 'c' => array( + 'status' => 'good', + ), + 'd' => array( + // An unrecognized status is rejected. + 'status' => 'bogus', + ), + ) + ) + ); + + $response = $this->dispatch_result_request(); + $this->assertFalse( $response['success'] ); + $this->assertSame( 'rest_not_in_enum', $response['data'][0]['code'] ); + + $_POST['results'] = wp_slash( + wp_json_encode( + array( + 'a' => array( + 'status' => 'critical', + ), + 'b' => array( + 'status' => 'recommended', + ), + 'c' => array( + 'status' => 'good', + ), + ) + ) + ); + + $response = $this->dispatch_result_request(); + $this->assertTrue( $response['success'] ); + + // The counts transient holds only the aggregate counts. + $transient = get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + $this->assertIsString( $transient ); + $this->assertSame( + array( + 'good' => 5, + 'recommended' => 1, + 'critical' => 2, + ), + json_decode( $transient, true ) + ); + + // Only results with a recognized status are cached. + $results = WP_Site_Health::get_site_status_detail()['results']; + $this->assertSame( array( 'a', 'b', 'c' ), array_keys( $results ) ); + + // Only locale-independent fields are cached: the test name, status, and timestamp. + $this->assertSameSets( + array( 'status', 'timestamp' ), + array_keys( $results['a'] ), + 'No translated or HTML fields should be cached.' + ); + $this->assertSame( 'critical', $results['a']['status'] ); + + // Each cached result carries a timestamp. + $this->assertIsInt( $results['a']['timestamp'] ); + $this->assertGreaterThan( 0, $results['a']['timestamp'] ); + + // The detail cache exposes counts derived from its own (sanitized) results. + $this->assertSame( + array( + 'good' => 1, + 'recommended' => 1, + 'critical' => 1, + ), + WP_Site_Health::get_site_status_detail()['counts'] + ); + } + + /** + * Posting only the results updates the detailed cache without touching the counts cache. + * + * @ticket 65232 + */ + public function test_posting_results_only_updates_detail_without_counts(): void { + delete_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + $this->set_user_with_site_health_capability(); + $_POST['_wpnonce'] = wp_create_nonce( self::ACTION ); + $_POST['results'] = wp_slash( + wp_json_encode( + array( + 'a' => array( + 'status' => 'critical', + ), + ) + ) + ); + + $response = $this->dispatch_result_request(); + + $this->assertTrue( $response['success'] ); + + // The detailed cache is updated. + $results = WP_Site_Health::get_site_status_detail()['results']; + $this->assertSame( array( 'a' ), array_keys( $results ) ); + + // The counts cache is not written by a results-only request. + $this->assertFalse( get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ) ); + } + + /** + * Posting only counts refreshes the counts cache and leaves the detailed cache intact. + * + * @ticket 65232 + */ + public function test_posting_counts_only_leaves_detail_intact(): void { + $now = time(); + set_transient( + WP_Site_Health::STATUS_DETAIL_TRANSIENT, + wp_json_encode( + array( + 'results' => array( + 'seeded' => array( + 'status' => 'good', + 'timestamp' => $now, + ), + ), + 'timestamp' => $now, + ) + ), + MONTH_IN_SECONDS + ); + + $this->set_user_with_site_health_capability(); + $_POST['_wpnonce'] = wp_create_nonce( self::ACTION ); + $_POST['counts'] = array( + 'good' => 3, + 'recommended' => 0, + 'critical' => 0, + ); + + $response = $this->dispatch_result_request(); + + $this->assertTrue( $response['success'] ); + + $transient = get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + $this->assertIsString( $transient ); + $this->assertSame( + array( + 'good' => 3, + 'recommended' => 0, + 'critical' => 0, + ), + json_decode( $transient, true ) + ); + + $results = WP_Site_Health::get_site_status_detail()['results']; + $this->assertSame( array( 'seeded' ), array_keys( $results ), 'The detailed cache should be untouched.' ); + } + + /** + * Non-array counts are rejected and leave both caches untouched. + * + * @ticket 65232 + */ + public function test_invalid_counts_return_error_and_write_nothing(): void { + $existing = array( 'good' => 9 ); + set_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT, wp_json_encode( $existing ) ); + + $this->set_user_with_site_health_capability(); + $_POST['_wpnonce'] = wp_create_nonce( self::ACTION ); + // No 'counts' are supplied. + + $response = $this->dispatch_result_request(); + + $this->assertFalse( $response['success'] ); + + $transient = get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + $this->assertIsString( $transient ); + $this->assertSame( $existing, json_decode( $transient, true ), 'The counts cache should be untouched.' ); + $this->assertFalse( get_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ) ); + } + + /** + * Users without the capability cannot write to either cache. + * + * @ticket 65232 + */ + public function test_user_without_capability_cannot_write_cache(): void { + delete_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + $this->_setRole( 'subscriber' ); + $_POST['_wpnonce'] = wp_create_nonce( self::ACTION ); + $_POST['counts'] = array( + 'good' => 1, + 'recommended' => 1, + 'critical' => 1, + ); + $_POST['results'] = wp_slash( + wp_json_encode( + array( + array( + 'test' => 'a', + 'label' => 'A', + 'status' => 'critical', + ), + ) + ) + ); + + $response = $this->dispatch_result_request(); + + $this->assertFalse( $response['success'] ); + $this->assertFalse( get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ) ); + $this->assertFalse( get_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ) ); + } +} diff --git a/tests/qunit/index.html b/tests/qunit/index.html index 9fd35f0c1ffc2..27632d4545cea 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -87,9 +87,11 @@ + + @@ -100,6 +102,28 @@ + + @@ -151,6 +175,7 @@ + diff --git a/tests/qunit/wp-admin/js/site-health.js b/tests/qunit/wp-admin/js/site-health.js new file mode 100644 index 0000000000000..8d3c17d8ec9f3 --- /dev/null +++ b/tests/qunit/wp-admin/js/site-health.js @@ -0,0 +1,24 @@ +/* global SiteHealth */ + +QUnit.module( 'Site Health', function() { + QUnit.test( 'updates state only for valid direct tests', function( assert ) { + assert.deepEqual( + SiteHealth.site_status.results, + { + direct_test: { + status: 'good' + } + }, + 'The test results contain locale-independent data.' + ); + assert.deepEqual( + SiteHealth.site_status.issues, + { + critical: 0, + good: 1, + recommended: 0 + }, + 'The issue counts reflect the test results.' + ); + } ); +} );