diff --git a/src/js/_enqueues/admin/site-health.js b/src/js/_enqueues/admin/site-health.js index 57d5c9cbcf289..7d175d9443aae 100644 --- a/src/js/_enqueues/admin/site-health.js +++ b/src/js/_enqueues/admin/site-health.js @@ -161,6 +161,19 @@ jQuery( function( $ ) { issue.test = issue.status + count; } + if ( 'critical' === issue.status || 'recommended' === issue.status ) { + if ( 'undefined' === typeof SiteHealth.site_status.issue_list ) { + SiteHealth.site_status.issue_list = []; + } + + SiteHealth.site_status.issue_list.push( { + test: issue.test, + label: issue.label, + status: issue.status, + description: issue.description + } ); + } + if ( 'critical' === issue.status ) { heading = sprintf( _n( '%s critical issue', '%s critical issues', count ), @@ -250,13 +263,19 @@ jQuery( function( $ ) { } if ( isStatusTab ) { + var postData = { + 'action': 'health-check-site-status-result', + '_wpnonce': SiteHealth.nonce.site_status_result, + 'counts': SiteHealth.site_status.issues + }; + + if ( 'undefined' !== typeof SiteHealth.site_status.issue_list ) { + postData.issues = JSON.stringify( SiteHealth.site_status.issue_list ); + } + $.post( ajaxurl, - { - 'action': 'health-check-site-status-result', - '_wpnonce': SiteHealth.nonce.site_status_result, - 'counts': SiteHealth.site_status.issues - } + postData ); if ( 100 === val ) { @@ -375,6 +394,7 @@ jQuery( function( $ ) { 'recommended': 0, 'critical': 0 }; + SiteHealth.site_status.issue_list = []; } 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..12d09079ac5db 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -5465,8 +5465,56 @@ function wp_ajax_health_check_site_status_result() { if ( ! current_user_can( 'view_site_health_checks' ) ) { wp_send_json_error(); } + $counts = isset( $_POST['counts'] ) ? wp_unslash( $_POST['counts'] ) : null; + if ( ! is_array( $counts ) ) { + wp_send_json_error(); + } + + $payload = array( + 'good' => isset( $counts['good'] ) ? (int) $counts['good'] : 0, + 'recommended' => isset( $counts['recommended'] ) ? (int) $counts['recommended'] : 0, + 'critical' => isset( $counts['critical'] ) ? (int) $counts['critical'] : 0, + ); + + $has_actionable_issues = $payload['recommended'] > 0 || $payload['critical'] > 0; + $previous = get_transient( 'health-check-site-status-result' ); + $previous = is_string( $previous ) ? json_decode( $previous, true ) : array(); + + if ( isset( $_POST['issues'] ) ) { + $issues_raw = wp_unslash( $_POST['issues'] ); + $decoded = is_string( $issues_raw ) ? json_decode( $issues_raw, true ) : null; + + if ( is_array( $decoded ) ) { + $sanitized_issues = array(); + foreach ( $decoded as $issue ) { + if ( ! is_array( $issue ) ) { + continue; + } + + $status = isset( $issue['status'] ) ? sanitize_key( $issue['status'] ) : ''; + if ( ! in_array( $status, array( 'recommended', 'critical' ), true ) ) { + continue; + } + + $sanitized_issues[] = array( + 'test' => isset( $issue['test'] ) ? sanitize_text_field( $issue['test'] ) : '', + 'label' => isset( $issue['label'] ) ? sanitize_text_field( $issue['label'] ) : '', + 'status' => $status, + 'description' => isset( $issue['description'] ) ? wp_strip_all_tags( $issue['description'] ) : '', + ); + } + + $payload['issues'] = $sanitized_issues; + } elseif ( $has_actionable_issues && is_array( $previous ) && isset( $previous['issues'] ) && is_array( $previous['issues'] ) ) { + $payload['issues'] = $previous['issues']; + } + } elseif ( $has_actionable_issues && is_array( $previous ) && isset( $previous['issues'] ) && is_array( $previous['issues'] ) ) { + $payload['issues'] = $previous['issues']; + } + + $payload['timestamp'] = time(); - set_transient( 'health-check-site-status-result', wp_json_encode( $_POST['counts'] ) ); + set_transient( 'health-check-site-status-result', wp_json_encode( $payload ) ); 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..fe3e6b5052a15 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -3372,6 +3372,7 @@ public function wp_cron_scheduled_check() { 'good' => 0, 'recommended' => 0, 'critical' => 0, + 'issues' => array(), ); // Don't run https test on development environments. @@ -3454,6 +3455,10 @@ public function wp_cron_scheduled_check() { } foreach ( $results as $result ) { + if ( ! is_array( $result ) || ! isset( $result['status'] ) ) { + continue; + } + if ( 'critical' === $result['status'] ) { ++$site_status['critical']; } elseif ( 'recommended' === $result['status'] ) { @@ -3461,8 +3466,21 @@ public function wp_cron_scheduled_check() { } else { ++$site_status['good']; } + + if ( ! in_array( $result['status'], array( 'recommended', 'critical' ), true ) ) { + continue; + } + + $site_status['issues'][] = array( + 'test' => isset( $result['test'] ) ? (string) $result['test'] : '', + 'label' => isset( $result['label'] ) ? wp_strip_all_tags( (string) $result['label'] ) : '', + 'status' => $result['status'], + 'description' => isset( $result['description'] ) ? wp_strip_all_tags( (string) $result['description'] ) : '', + ); } + $site_status['timestamp'] = time(); + set_transient( 'health-check-site-status-result', wp_json_encode( $site_status ) ); } diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 4c6db1ed830e0..144200235418f 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -196,53 +196,164 @@ function wp_register_core_abilities(): void { ) ); + $env_info_properties = array( + 'environment' => array( + 'type' => 'string', + 'description' => __( 'The site\'s runtime environment classification (can be one of these: production, staging, development, local).' ), + 'enum' => array( 'production', 'staging', 'development', 'local' ), + ), + 'php_version' => array( + 'type' => 'string', + 'description' => __( 'The PHP runtime version executing WordPress.' ), + ), + 'db_server_info' => array( + 'type' => 'string', + 'description' => __( 'The database server vendor and version string reported by the driver.' ), + ), + 'wp_version' => array( + 'type' => 'string', + 'description' => __( 'The WordPress core version running on this site.' ), + ), + 'site_health' => array( + 'type' => 'object', + 'description' => __( 'A high-level overview of the site\'s health, populated from cached data. Can vary across calls as the cache refreshes.' ), + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'title' => __( 'Status' ), + 'description' => __( 'The overall health status of the site (e.g., good, recommended, critical, unknown).' ), + 'enum' => array( 'unknown', 'good', 'recommended', 'critical' ), + ), + 'counts' => array( + 'type' => 'object', + 'title' => __( 'Counts' ), + 'description' => __( 'The count of Site Health test results by severity.' ), + 'properties' => array( + 'good' => array( + 'type' => 'integer', + 'title' => __( 'Good' ), + 'description' => __( 'Number of passing tests.' ), + ), + 'recommended' => array( + 'type' => 'integer', + 'title' => __( 'Recommended' ), + 'description' => __( 'Number of recommended improvements.' ), + ), + 'critical' => array( + 'type' => 'integer', + 'title' => __( 'Critical' ), + 'description' => __( 'Number of critical issues.' ), + ), + ), + 'additionalProperties' => false, + ), + 'issues' => array( + 'type' => 'array', + 'title' => __( 'Issues' ), + 'description' => __( 'Actionable issues, capped at 10 items.' ), + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'test' => array( + 'type' => 'string', + 'title' => __( 'Test Identifier' ), + 'description' => __( 'The machine-readable name of the Site Health test.' ), + ), + 'label' => array( + 'type' => 'string', + 'title' => __( 'Label' ), + 'description' => __( 'Short description of the issue.' ), + ), + 'severity' => array( + 'type' => 'string', + 'title' => __( 'Severity' ), + 'description' => __( 'Severity level (recommended, critical).' ), + 'enum' => array( 'recommended', 'critical' ), + ), + 'recommendation' => array( + 'type' => 'string', + 'title' => __( 'Recommendation' ), + 'description' => __( 'Guidance or description for resolving the issue.' ), + ), + ), + 'additionalProperties' => false, + ), + ), + 'truncated' => array( + 'type' => 'boolean', + 'title' => __( 'Truncated' ), + 'description' => __( 'Whether the list of issues has been truncated due to size limits.' ), + ), + 'timestamp' => array( + 'type' => 'integer', + 'title' => __( 'Timestamp' ), + 'description' => __( 'The Unix timestamp of when the Site Health data was last collected, or 0 when no cached data exists.' ), + ), + ), + 'additionalProperties' => false, + ), + ); + $env_info_fields = array_keys( $env_info_properties ); + wp_register_ability( 'core/get-environment-info', array( 'label' => __( 'Get Environment Info' ), 'description' => __( 'Returns core details about the site\'s runtime context for diagnostics and compatibility (environment, PHP runtime, database server info, WordPress version).' ), 'category' => $category_site, - 'output_schema' => array( + 'input_schema' => array( 'type' => 'object', - 'required' => array( 'environment', 'php_version', 'db_server_info', 'wp_version' ), 'properties' => array( - 'environment' => array( - 'type' => 'string', - 'description' => __( 'The site\'s runtime environment classification (can be one of these: production, staging, development, local).' ), - 'enum' => array( 'production', 'staging', 'development', 'local' ), - ), - 'php_version' => array( - 'type' => 'string', - 'description' => __( 'The PHP runtime version executing WordPress.' ), - ), - 'db_server_info' => array( - 'type' => 'string', - 'description' => __( 'The database server vendor and version string reported by the driver.' ), - ), - 'wp_version' => array( - 'type' => 'string', - 'description' => __( 'The WordPress core version running on this site.' ), + 'fields' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => $env_info_fields, + ), + 'description' => __( 'Optional: Limit response to specific fields. If omitted, all fields are returned.' ), ), ), 'additionalProperties' => false, + 'default' => array(), ), - 'execute_callback' => static function (): array { + 'output_schema' => array( + 'type' => 'object', + 'properties' => $env_info_properties, + 'additionalProperties' => false, + ), + 'execute_callback' => static function ( $input = array() ) use ( $env_info_fields ): array { global $wpdb; - $env = wp_get_environment_type(); - $php_version = phpversion(); - $db_server_info = ''; - if ( method_exists( $wpdb, 'db_server_info' ) ) { - $db_server_info = $wpdb->db_server_info() ?? ''; + $input = is_array( $input ) ? $input : array(); + $requested_fields = ! empty( $input['fields'] ) ? $input['fields'] : $env_info_fields; + + $result = array(); + + if ( in_array( 'environment', $requested_fields, true ) ) { + $result['environment'] = wp_get_environment_type(); } - $wp_version = get_bloginfo( 'version' ); - return array( - 'environment' => $env, - 'php_version' => $php_version, - 'db_server_info' => $db_server_info, - 'wp_version' => $wp_version, - ); + if ( in_array( 'php_version', $requested_fields, true ) ) { + $result['php_version'] = phpversion(); + } + + if ( in_array( 'db_server_info', $requested_fields, true ) ) { + $db_server_info = ''; + if ( method_exists( $wpdb, 'db_server_info' ) ) { + $db_server_info = $wpdb->db_server_info() ?? ''; + } + $result['db_server_info'] = $db_server_info; + } + + if ( in_array( 'wp_version', $requested_fields, true ) ) { + $result['wp_version'] = get_bloginfo( 'version' ); + } + + if ( in_array( 'site_health', $requested_fields, true ) ) { + $result['site_health'] = wp_get_abilities_api_site_health_summary_from_cache(); + } + + return $result; }, 'permission_callback' => static function (): bool { return current_user_can( 'manage_options' ); @@ -258,3 +369,88 @@ function wp_register_core_abilities(): void { ) ); } + +/** + * Builds the Site Health portion of `core/get-environment-info` from cached results only. + * + * @since 6.9.1 + * + * @return array{ + * status: 'unknown'|'good'|'recommended'|'critical', + * counts: array{good: int, recommended: int, critical: int}, + * issues: array, + * truncated: bool, + * timestamp: int + * } + */ +function wp_get_abilities_api_site_health_summary_from_cache(): array { + $site_health = array( + 'status' => 'unknown', + 'counts' => array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ), + 'issues' => array(), + 'truncated' => false, + 'timestamp' => 0, + ); + + $cached_health = get_transient( 'health-check-site-status-result' ); + + if ( false === $cached_health ) { + return $site_health; + } + + $health_data = json_decode( $cached_health, true ); + if ( ! is_array( $health_data ) ) { + return $site_health; + } + + if ( isset( $health_data['good'], $health_data['recommended'], $health_data['critical'] ) ) { + $site_health['counts']['good'] = (int) $health_data['good']; + $site_health['counts']['recommended'] = (int) $health_data['recommended']; + $site_health['counts']['critical'] = (int) $health_data['critical']; + + if ( $site_health['counts']['critical'] > 0 ) { + $site_health['status'] = 'critical'; + } elseif ( $site_health['counts']['recommended'] > 0 ) { + $site_health['status'] = 'recommended'; + } else { + $site_health['status'] = 'good'; + } + } + + if ( isset( $health_data['timestamp'] ) ) { + $site_health['timestamp'] = (int) $health_data['timestamp']; + } + + if ( isset( $health_data['issues'] ) && is_array( $health_data['issues'] ) ) { + $issues_count = 0; + foreach ( $health_data['issues'] as $issue ) { + if ( ! is_array( $issue ) ) { + continue; + } + + $status = isset( $issue['status'] ) ? (string) $issue['status'] : ''; + if ( ! in_array( $status, array( 'recommended', 'critical' ), true ) ) { + continue; + } + + if ( $issues_count >= 10 ) { + $site_health['truncated'] = true; + break; + } + + $site_health['issues'][] = array( + 'test' => isset( $issue['test'] ) ? (string) $issue['test'] : '', + 'label' => isset( $issue['label'] ) ? wp_strip_all_tags( (string) $issue['label'] ) : '', + 'severity' => $status, + 'recommendation' => isset( $issue['description'] ) ? wp_strip_all_tags( (string) $issue['description'] ) : '', + ); + ++$issues_count; + } + } + + return $site_health; +} diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php index 48cae6efd1dee..c10271181edaa 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php @@ -156,15 +156,18 @@ public function test_core_get_current_user_info_returns_user_data(): void { /** * Tests executing the environment info ability. + * * @ticket 64146 + * @ticket 65232 */ public function test_core_get_environment_info_executes(): void { // Requires manage_options. $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); wp_set_current_user( $admin_id ); - $ability = wp_get_ability( 'core/get-environment-info' ); - $environment = wp_get_environment_type(); + $ability = wp_get_ability( 'core/get-environment-info' ); + $environment = wp_get_environment_type(); + $ability_data = $ability->execute(); $this->assertIsArray( $ability_data ); @@ -172,9 +175,223 @@ public function test_core_get_environment_info_executes(): void { $this->assertArrayHasKey( 'php_version', $ability_data ); $this->assertArrayHasKey( 'db_server_info', $ability_data ); $this->assertArrayHasKey( 'wp_version', $ability_data ); + $this->assertArrayHasKey( 'site_health', $ability_data ); $this->assertSame( $environment, $ability_data['environment'] ); } + /** + * Tests that the `fields` input limits `core/get-environment-info` output. + * + * @ticket 65232 + */ + public function test_core_get_environment_info_fields_filtering(): void { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $ability = wp_get_ability( 'core/get-environment-info' ); + $filtered_data = $ability->execute( array( 'fields' => array( 'php_version', 'site_health' ) ) ); + + $this->assertCount( 2, $filtered_data ); + $this->assertArrayHasKey( 'php_version', $filtered_data ); + $this->assertArrayHasKey( 'site_health', $filtered_data ); + $this->assertArrayNotHasKey( 'environment', $filtered_data ); + $this->assertArrayNotHasKey( 'db_server_info', $filtered_data ); + $this->assertArrayNotHasKey( 'wp_version', $filtered_data ); + } + + /** + * Tests `site_health` in `core/get-environment-info` when no Site Health cache exists. + * + * @ticket 65232 + */ + public function test_core_get_environment_info_site_health_without_cache(): void { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + delete_transient( 'health-check-site-status-result' ); + + $ability = wp_get_ability( 'core/get-environment-info' ); + $site_health = $ability->execute( array( 'fields' => array( 'site_health' ) ) )['site_health']; + + $this->assertSame( 'unknown', $site_health['status'] ); + $this->assertSameSetsWithIndex( + array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ), + $site_health['counts'] + ); + $this->assertSame( array(), $site_health['issues'] ); + $this->assertFalse( $site_health['truncated'] ); + $this->assertSame( 0, $site_health['timestamp'] ); + } + + /** + * Tests `site_health` in `core/get-environment-info` when cached Site Health results exist. + * + * @ticket 65232 + */ + public function test_core_get_environment_info_site_health_from_cache(): void { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $cached_data = array( + 'good' => 8, + 'recommended' => 1, + 'critical' => 1, + 'issues' => array( + array( + 'test' => 'wordpress_version', + 'label' => 'WordPress update available', + 'status' => 'recommended', + 'description' => '

A new version of WordPress is available.

', + ), + array( + 'test' => 'authorization_header', + 'label' => 'Authorization header missing', + 'status' => 'critical', + 'description' => '

The authorization header is missing.

', + ), + ), + 'timestamp' => 1715714399, + ); + set_transient( 'health-check-site-status-result', wp_json_encode( $cached_data ) ); + + $ability = wp_get_ability( 'core/get-environment-info' ); + $site_health = $ability->execute( array( 'fields' => array( 'site_health' ) ) )['site_health']; + + $this->assertSame( 'critical', $site_health['status'] ); + $this->assertSame( 8, $site_health['counts']['good'] ); + $this->assertSame( 1, $site_health['counts']['recommended'] ); + $this->assertSame( 1, $site_health['counts']['critical'] ); + $this->assertSame( 1715714399, $site_health['timestamp'] ); + $this->assertFalse( $site_health['truncated'] ); + $this->assertSame( + array( + 'test' => 'wordpress_version', + 'label' => 'WordPress update available', + 'severity' => 'recommended', + 'recommendation' => 'A new version of WordPress is available.', + ), + $site_health['issues'][0] + ); + } + + /** + * Tests that only actionable Site Health issues are exposed and capped. + * + * @ticket 65232 + */ + public function test_core_get_environment_info_site_health_issues_are_actionable_and_truncated(): void { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $issues = array( + array( + 'test' => 'passing_test', + 'label' => 'Passing test', + 'status' => 'good', + 'description' => 'This should not be exposed.', + ), + array( + 'test' => 'unknown_status', + 'label' => 'Unknown status', + 'status' => 'invalid', + 'description' => 'This should not be exposed.', + ), + ); + + for ( $i = 0; $i < 11; $i++ ) { + $issues[] = array( + 'test' => 'test_' . $i, + 'label' => 'Issue ' . $i, + 'status' => 'recommended', + 'description' => 'Description ' . $i, + ); + } + + set_transient( + 'health-check-site-status-result', + wp_json_encode( + array( + 'good' => 1, + 'recommended' => 11, + 'critical' => 0, + 'issues' => $issues, + 'timestamp' => 1715714399, + ) + ) + ); + + $ability = wp_get_ability( 'core/get-environment-info' ); + $site_health = $ability->execute( array( 'fields' => array( 'site_health' ) ) )['site_health']; + + $this->assertTrue( $site_health['truncated'] ); + $this->assertCount( 10, $site_health['issues'] ); + $this->assertSame( 'test_0', $site_health['issues'][0]['test'] ); + $this->assertSame( 'test_9', $site_health['issues'][9]['test'] ); + } + + /** + * Tests that malformed cached Site Health data is treated as unknown. + * + * @ticket 65232 + */ + public function test_core_get_environment_info_site_health_with_malformed_cache(): void { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + set_transient( 'health-check-site-status-result', '{not-json' ); + + $ability = wp_get_ability( 'core/get-environment-info' ); + $site_health = $ability->execute( array( 'fields' => array( 'site_health' ) ) )['site_health']; + + $this->assertSame( 'unknown', $site_health['status'] ); + $this->assertSame( array(), $site_health['issues'] ); + } + + /** + * Tests that the environment info ability reads cached Site Health data only. + * + * @ticket 65232 + */ + public function test_core_get_environment_info_site_health_does_not_run_site_health_tests(): void { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $fail_on_test_discovery = function ( $tests ) { + $this->fail( 'core/get-environment-info must not run or discover Site Health tests.' ); + return $tests; + }; + + add_filter( 'site_status_tests', $fail_on_test_discovery ); + + $ability = wp_get_ability( 'core/get-environment-info' ); + $site_health = $ability->execute( array( 'fields' => array( 'site_health' ) ) )['site_health']; + + remove_filter( 'site_status_tests', $fail_on_test_discovery ); + + $this->assertIsArray( $site_health ); + } + + /** + * Tests that the environment info ability documents the Site Health schema precisely. + * + * @ticket 65232 + */ + public function test_core_get_environment_info_site_health_schema(): void { + $ability = wp_get_ability( 'core/get-environment-info' ); + $site_health = $ability->get_output_schema()['properties']['site_health']; + $issue_schema = $site_health['properties']['issues']['items']; + + $this->assertSame( array( 'unknown', 'good', 'recommended', 'critical' ), $site_health['properties']['status']['enum'] ); + $this->assertFalse( $site_health['additionalProperties'] ); + $this->assertFalse( $site_health['properties']['counts']['additionalProperties'] ); + $this->assertSame( array( 'recommended', 'critical' ), $issue_schema['properties']['severity']['enum'] ); + $this->assertFalse( $issue_schema['additionalProperties'] ); + } + /** * Tests that all core ability schemas only use valid JSON Schema keywords. *