diff --git a/src/js/_enqueues/admin/site-health.js b/src/js/_enqueues/admin/site-health.js index 57d5c9cbcf289..55ef5b15ecc99 100644 --- a/src/js/_enqueues/admin/site-health.js +++ b/src/js/_enqueues/admin/site-health.js @@ -161,6 +161,23 @@ jQuery( function( $ ) { issue.test = issue.status + count; } + /* + * Collect the full result so it can be cached server-side. These results + * include the asynchronous tests that only run in the browser. + */ + if ( 'undefined' === typeof SiteHealth.site_status.results ) { + SiteHealth.site_status.results = []; + } + + SiteHealth.site_status.results.push( { + test: issue.test, + label: issue.label, + status: issue.status, + badge: issue.badge, + description: issue.description, + actions: issue.actions + } ); + if ( 'critical' === issue.status ) { heading = sprintf( _n( '%s critical issue', '%s critical issues', count ), @@ -250,6 +267,7 @@ jQuery( function( $ ) { } if ( isStatusTab ) { + // Refresh the lightweight counts first, so a large detailed payload can't block them. $.post( ajaxurl, { @@ -259,6 +277,18 @@ jQuery( function( $ ) { } ); + // Send the full results separately, as a best-effort detailed cache update. + if ( 'undefined' !== typeof SiteHealth.site_status.results ) { + $.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' ); @@ -375,6 +405,7 @@ jQuery( function( $ ) { 'recommended': 0, 'critical': 0 }; + SiteHealth.site_status.results = []; } if ( 0 < SiteHealth.site_status.direct.length ) { diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 2af08fba70af9..588110b6e058a 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,37 @@ 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'; + } + + $updated = false; + + // Refresh the lightweight, autoloaded aggregate counts used by the admin menu and Dashboard. + if ( isset( $_POST['counts'] ) && is_array( $_POST['counts'] ) ) { + $counts = wp_unslash( $_POST['counts'] ); + WP_Site_Health::set_site_status_counts( $counts ); + + $updated = true; + } + + /* + * 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 ) ) { + WP_Site_Health::update_site_status_detail( $results, true ); + $updated = true; + } + } + + if ( ! $updated ) { + wp_send_json_error(); + } 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 75e046ef8ffa7..60249d48cfee3 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -29,6 +29,26 @@ class WP_Site_Health { private $timeout_missed_cron = null; private $timeout_late_cron = null; + /** + * 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. * @@ -112,13 +132,7 @@ public function enqueue_scripts() { ), ); - $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(); @@ -3359,6 +3373,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. @@ -3401,7 +3417,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; } @@ -3445,7 +3461,9 @@ public function wp_cron_scheduled_check() { if ( is_array( $result ) ) { $results[] = $result; } else { + // Include the test identifier so the result is counted and cached consistently. $results[] = array( + 'test' => $test_name, 'status' => 'recommended', 'label' => __( 'A test is unavailable' ), ); @@ -3454,16 +3472,272 @@ public function wp_cron_scheduled_check() { } foreach ( $results as $result ) { - if ( 'critical' === $result['status'] ) { + if ( ! is_array( $result ) ) { + continue; + } + + $status = isset( $result['status'] ) ? $result['status'] : ''; + + if ( 'critical' === $status ) { ++$site_status['critical']; - } elseif ( 'recommended' === $result['status'] ) { + } elseif ( 'recommended' === $status ) { ++$site_status['recommended']; } else { ++$site_status['good']; } } - set_transient( 'health-check-site-status-result', wp_json_encode( $site_status ) ); + self::set_site_status_counts( $site_status ); + + /* + * Cache the full results separately, keyed by test, so consumers can read the + * detailed Site Health status without re-running the tests. + * + * The scheduled check is not authoritative: it refreshes only missing or stale + * entries so it does not discard fresher results collected from the Site Health + * screen, which also include the asynchronous tests that require JavaScript to run. + */ + self::update_site_status_detail( $results, false ); + } + + /** + * Returns the cached aggregate Site Health status counts. + * + * @since 7.1.0 + * + * @return array{good: int, recommended: int, critical: int} Aggregate counts. Each + * value is `0` when no + * result has been cached. + */ + public static function get_site_status_counts(): array { + $counts = array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ); + + $cached = get_transient( self::STATUS_RESULT_TRANSIENT ); + $cached = is_string( $cached ) ? json_decode( $cached, true ) : null; + + if ( ! is_array( $cached ) ) { + return $counts; + } + + $counts['good'] = (int) ( $cached['good'] ?? 0 ); + $counts['recommended'] = (int) ( $cached['recommended'] ?? 0 ); + $counts['critical'] = (int) ( $cached['critical'] ?? 0 ); + + return $counts; + } + + /** + * Caches the aggregate Site Health status counts. + * + * @since 7.1.0 + * + * @param array $counts Aggregate counts. + */ + public static function set_site_status_counts( array $counts ): void { + set_transient( + self::STATUS_RESULT_TRANSIENT, + wp_json_encode( + array( + 'good' => (int) ( $counts['good'] ?? 0 ), + 'recommended' => (int) ( $counts['recommended'] ?? 0 ), + 'critical' => (int) ( $counts['critical'] ?? 0 ), + ) + ) + ); + } + + /** + * 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 + * + * @return array{ + * results: list>, + * counts: array{good: int, recommended: int, critical: int}, + * timestamp: int, + * } The cached results as a list, 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. + */ + public static function get_site_status_detail(): array { + $detail = array( + 'results' => array(), + 'counts' => array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ), + 'timestamp' => 0, + ); + + $cached = get_transient( self::STATUS_DETAIL_TRANSIENT ); + $cached = is_string( $cached ) ? json_decode( $cached, true ) : null; + + if ( is_array( $cached ) && isset( $cached['results'] ) && is_array( $cached['results'] ) ) { + $detail['results'] = array_values( $cached['results'] ); + $detail['counts'] = self::count_site_status_results( $detail['results'] ); + $detail['timestamp'] = isset( $cached['timestamp'] ) ? (int) $cached['timestamp'] : 0; + } + + return $detail; + } + + /** + * 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 List of raw Site Health test result arrays. + * @param bool $authoritative Whether to overwrite existing cached entries for the + * same test. The Site Health screen passes `true` because + * its results include the JavaScript-only asynchronous + * tests. The scheduled check passes `false` so it refreshes + * only missing or stale entries without discarding fresher + * results collected from the screen. + */ + public static function update_site_status_detail( array $results, bool $authoritative ): void { + $now = time(); + + $cached = get_transient( self::STATUS_DETAIL_TRANSIENT ); + $cached = is_string( $cached ) ? json_decode( $cached, true ) : null; + + $stored = ( is_array( $cached ) && isset( $cached['results'] ) && is_array( $cached['results'] ) ) + ? $cached['results'] + : array(); + + foreach ( $results as $result ) { + $sanitized = self::sanitize_site_status_result( $result ); + + if ( null === $sanitized ) { + continue; + } + + $test = $sanitized['test']; + + /* + * When not authoritative, keep a recent existing entry rather than overwriting + * it. This preserves results collected from the Site Health screen (including + * the asynchronous tests) when the scheduled check runs afterwards. + */ + if ( ! $authoritative + && isset( $stored[ $test ]['timestamp'] ) + && ( $now - (int) $stored[ $test ]['timestamp'] ) < WEEK_IN_SECONDS + ) { + continue; + } + + $sanitized['timestamp'] = $now; + $stored[ $test ] = $sanitized; + } + + // Drop results that have not been refreshed within the last month. + foreach ( $stored as $test => $result ) { + if ( ! isset( $result['timestamp'] ) || ( $now - (int) $result['timestamp'] ) > MONTH_IN_SECONDS ) { + unset( $stored[ $test ] ); + } + } + + if ( empty( $stored ) ) { + delete_transient( self::STATUS_DETAIL_TRANSIENT ); + return; + } + + set_transient( + self::STATUS_DETAIL_TRANSIENT, + wp_json_encode( + array( + 'results' => $stored, + // A counts snapshot derived from the same results, kept for self-describing cache reads. + 'counts' => self::count_site_status_results( array_values( $stored ) ), + 'timestamp' => $now, + ) + ), + MONTH_IN_SECONDS + ); + } + + /** + * Counts a set of Site Health results by status. + * + * @since 7.1.0 + * + * @param array> $results List of result arrays. + * @return array{good: int, recommended: int, critical: int} Aggregate counts. Any + * unrecognized status is + * counted as `good`. + */ + private static function count_site_status_results( array $results ): array { + $counts = array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ); + + foreach ( $results as $result ) { + $status = isset( $result['status'] ) ? $result['status'] : ''; + + if ( 'critical' === $status ) { + ++$counts['critical']; + } elseif ( 'recommended' === $status ) { + ++$counts['recommended']; + } else { + ++$counts['good']; + } + } + + return $counts; + } + + /** + * Normalizes and sanitizes a single Site Health test result for caching. + * + * @since 7.1.0 + * + * @param mixed $result Raw test result data. + * @return array|null The sanitized result, or null when required fields are missing + * or the status is not recognized. + */ + private static function sanitize_site_status_result( $result ): ?array { + if ( ! is_array( $result ) ) { + return null; + } + + $test = isset( $result['test'] ) ? sanitize_text_field( (string) $result['test'] ) : ''; + $status = isset( $result['status'] ) ? sanitize_key( (string) $result['status'] ) : ''; + + if ( '' === $test || ! in_array( $status, array( 'good', 'recommended', 'critical' ), true ) ) { + return null; + } + + $sanitized = array( + 'test' => $test, + 'label' => isset( $result['label'] ) ? sanitize_text_field( (string) $result['label'] ) : '', + 'status' => $status, + 'description' => isset( $result['description'] ) ? wp_kses_post( (string) $result['description'] ) : '', + 'actions' => isset( $result['actions'] ) ? wp_kses_post( (string) $result['actions'] ) : '', + ); + + if ( isset( $result['badge'] ) && is_array( $result['badge'] ) ) { + $sanitized['badge'] = array( + 'label' => isset( $result['badge']['label'] ) ? sanitize_text_field( (string) $result['badge']['label'] ) : '', + 'color' => isset( $result['badge']['color'] ) ? sanitize_key( (string) $result['badge']['color'] ) : '', + ); + } + + return $sanitized; } /** diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 6080b477f54c3..1f1cccf1df288 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -707,4 +707,379 @@ 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 full 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 ); + + $before = time(); + $this->instance->wp_cron_scheduled_check(); + $after = time(); + + remove_filter( 'site_status_tests', $filter ); + + // The counts transient holds only the aggregate counts, so it stays small enough to autoload. + $transient = get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + $this->assertIsString( $transient ); + $this->assertSame( + array( + 'good' => 1, + 'recommended' => 1, + 'critical' => 1, + ), + json_decode( $transient, true ), + 'The counts transient should contain only the aggregate counts.' + ); + + // 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 = array(); + foreach ( $detail['results'] as $result ) { + $results_by_test[ $result['test'] ] = $result; + } + + $this->assertSame( + array( 'fake_critical', 'fake_recommended', 'fake_good' ), + array_keys( $results_by_test ) + ); + + // Safe HTML in the description is preserved. + $this->assertSame( + '

Critical description.

', + $results_by_test['fake_critical']['description'] + ); + $this->assertSame( + array( + 'label' => 'Security', + 'color' => 'red', + ), + $results_by_test['fake_critical']['badge'] + ); + + // 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.' + ); + } + + /** + * An asynchronous test that is unavailable during the scheduled check is cached under + * its own identifier, so the counts and the detailed results stay consistent. + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + */ + public function test_scheduled_check_caches_unavailable_async_test_with_identifier(): void { + // 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 ); + + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + $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 = array(); + foreach ( $detail['results'] as $result ) { + $results_by_id[ $result['test'] ] = $result; + } + + $this->assertArrayHasKey( 'my_async', $results_by_id, 'The unavailable async test should be cached under its identifier.' ); + $this->assertSame( 'recommended', $results_by_id['my_async']['status'] ); + $this->assertSame( 1, $detail['counts']['recommended'], 'The unavailable async test should also be reflected in the detail counts.' ); + } + + /** + * The scheduled check does not overwrite recently cached results, preserving the + * authoritative results collected from the Site Health screen (including async tests). + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + * @covers ::update_site_status_detail + */ + public function test_scheduled_check_preserves_fresh_results(): void { + $this->seed_site_status_detail( + array( + 'fake_async' => array( + 'test' => 'fake_async', + 'label' => 'Async', + 'status' => 'good', + 'description' => '

From the browser.

', + ), + ), + time() + ); + + $filter = $this->use_fake_site_status_tests( + array( + 'fake_async' => array( + 'test' => 'fake_async', + 'label' => 'Async', + 'status' => 'critical', + 'description' => '

From cron.

', + ), + ) + ); + + $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( 'good', $detail['results'][0]['status'], 'The fresh browser result should be preserved.' ); + $this->assertSame( '

From the browser.

', $detail['results'][0]['description'] ); + } + + /** + * The scheduled check refreshes results that are older than the cron cadence. + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + * @covers ::update_site_status_detail + */ + public function test_scheduled_check_refreshes_stale_results(): void { + $this->seed_site_status_detail( + array( + 'fake_test' => array( + 'test' => 'fake_test', + 'label' => 'Test', + 'status' => 'good', + 'description' => '

Stale.

', + ), + ), + time() - 2 * WEEK_IN_SECONDS + ); + + $filter = $this->use_fake_site_status_tests( + array( + 'fake_test' => array( + 'test' => 'fake_test', + 'label' => 'Test', + 'status' => 'critical', + 'description' => '

Refreshed by cron.

', + ), + ) + ); + + $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', $detail['results'][0]['status'], 'A stale result should be refreshed by cron.' ); + $this->assertSame( '

Refreshed by cron.

', $detail['results'][0]['description'] ); + } + + /** + * 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( + 'test' => 'recent', + 'status' => 'good', + 'timestamp' => $now, + ), + 'old' => array( + 'test' => 'old', + '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(), true ); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertCount( 1, $detail['results'] ); + $this->assertSame( 'recent', $detail['results'][0]['test'] ); + } + + /** + * 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 detail accessor returns an empty array when nothing is cached. + * + * @ticket 65232 + * + * @covers ::get_site_status_detail + */ + public function test_get_site_status_detail_empty_when_uncached(): void { + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + $this->assertSame( + array( + 'results' => array(), + 'counts' => array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ), + 'timestamp' => 0, + ), + WP_Site_Health::get_site_status_detail() + ); + } } diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php new file mode 100644 index 0000000000000..105469efadee6 --- /dev/null +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -0,0 +1,338 @@ +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; + } + + /** + * Returns the detailed cached results keyed by test name. + * + * @return array + */ + private function get_detail_results_by_test(): array { + $detail = WP_Site_Health::get_site_status_detail(); + + if ( empty( $detail['results'] ) ) { + return array(); + } + + $by_test = array(); + foreach ( $detail['results'] as $result ) { + $by_test[ $result['test'] ] = $result; + } + + return $by_test; + } + + /** + * The aggregate counts are cached on their own, while the full 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( + array( + 'test' => 'a', + 'label' => 'A', + 'status' => 'critical', + 'description' => '

Bad

', + 'actions' => '', + ), + array( + 'test' => 'b', + 'label' => 'B', + 'status' => 'recommended', + 'description' => '

B

', + ), + array( + 'test' => 'c', + 'label' => 'C', + 'status' => 'good', + 'description' => '

C

', + ), + array( + // An unrecognized status is dropped. + 'test' => 'd', + 'label' => 'D', + 'status' => 'bogus', + 'description' => '

D

', + ), + ) + ) + ); + + $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 = $this->get_detail_results_by_test(); + $this->assertSame( array( 'a', 'b', 'c' ), array_keys( $results ) ); + + // Disallowed HTML is stripped from the cached description. + $this->assertStringNotContainsString( '