diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index dd537296a8655..c4501eaa09d2a 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -29,6 +29,21 @@ class WP_Site_Health { private $timeout_missed_cron = null; private $timeout_late_cron = null; + /** + * @var bool + */ + private $wp_debug; + + /** + * @var bool|string + */ + private $wp_debug_log; + + /** + * @var bool|null + */ + private $wp_debug_display; + /** * WP_Site_Health constructor. * @@ -54,6 +69,10 @@ public function __construct() { add_action( 'wp_site_health_scheduled_check', array( $this, 'wp_cron_scheduled_check' ) ); add_action( 'site_health_tab_content', array( $this, 'show_site_health_tab' ) ); + + $this->wp_debug = defined( 'WP_DEBUG' ) && WP_DEBUG; + $this->wp_debug_log = defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG; + $this->wp_debug_display = defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY ? WP_DEBUG_DISPLAY : null; } /** @@ -1383,7 +1402,7 @@ public function get_test_dotorg_communication() { * * @since 5.2.0 * - * @return array The test results. + * @return array> The test results. */ public function get_test_is_in_debug_mode() { $result = array( @@ -1408,23 +1427,80 @@ public function get_test_is_in_debug_mode() { 'test' => 'is_in_debug_mode', ); - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { - $result['label'] = __( 'Your site is set to log errors to a potentially public file' ); + if ( $this->wp_debug ) { + if ( ! empty( ini_get( 'error_log' ) ) ) { + $debug_log_dir = realpath( dirname( ini_get( 'error_log' ) ) ); + $absolute_path = realpath( ABSPATH ) . DIRECTORY_SEPARATOR; - $result['status'] = str_starts_with( ini_get( 'error_log' ), ABSPATH ) ? 'critical' : 'recommended'; + if ( false === $debug_log_dir ) { + $log_path_status = 'error'; + } elseif ( str_starts_with( $debug_log_dir . DIRECTORY_SEPARATOR, $absolute_path ) ) { + $log_path_status = 'public'; + } else { + $log_path_status = 'private'; + } - $result['description'] .= sprintf( - '

%s

', - sprintf( - /* translators: %s: WP_DEBUG_LOG */ - __( 'The value, %s, has been added to this website’s configuration file. This means any errors on the site will be written to a file which is potentially available to all users.' ), - 'WP_DEBUG_LOG' - ) - ); + if ( 'public' === $log_path_status ) { + $result['label'] = __( 'Your site is set to log errors to a potentially public file' ); + $result['status'] = 'critical'; + + if ( $this->wp_debug_log ) { + $result['description'] .= sprintf( + '

%s

', + sprintf( + /* translators: %s: WP_DEBUG_LOG */ + __( 'The constant, %s, has been added to this website’s configuration file. This means any errors on the site will be written to a file which is likely publicly accessible.' ), + 'WP_DEBUG_LOG' + ) + ); + } else { + $result['description'] .= sprintf( + '

%s

', + __( 'The error log path has been configured to a file within the WordPress directory. This means any errors on the site will be written to a file which is likely publicly accessible.' ) + ); + } + } elseif ( 'private' === $log_path_status ) { + $result['label'] = __( 'Your site is set to log errors to a file outside the document root' ); + $result['status'] = 'good'; + + if ( $this->wp_debug_log ) { + $result['description'] .= sprintf( + '

%s

', + sprintf( + /* translators: %s: WP_DEBUG_LOG */ + __( 'The configuration constant, %s, is enabled. In addition, your site is set to write errors to a file outside the WordPress directory, which is a good practice as the log file should not be publicly accessible.' ), + 'WP_DEBUG_LOG' + ) + ); + } else { + $result['description'] .= sprintf( + '

%s

', + __( 'The error log path has been configured to a file outside the WordPress directory. This is a good practice as the log file should not be publicly accessible.' ) + ); + } + } else { + $result['label'] = __( 'Unable to determine error log file location' ); + $result['status'] = 'critical'; + + if ( $this->wp_debug_log ) { + $result['description'] .= sprintf( + '

%s

', + sprintf( + /* translators: %s: WP_DEBUG_LOG */ + __( 'The configuration constant, %s, is enabled, but the log file location could not be determined.' ), + 'WP_DEBUG_LOG' + ) + ); + } else { + $result['description'] .= sprintf( + '

%s

', + __( 'The error log path could not be determined. Please check your PHP configuration.' ) + ); + } + } } - if ( defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY ) { + if ( $this->wp_debug_display ) { $result['label'] = __( 'Your site is set to display errors to site visitors' ); $result['status'] = 'critical'; diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 0c6a42f71bea3..5075fb0ba3e7b 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -572,4 +572,374 @@ public static function set_autoloaded_option( $bytes = 800000 ) { // Force autoloading so that WordPress core does not override it. See https://core.trac.wordpress.org/changeset/57920. add_option( 'test_set_autoloaded_option', $heavy_option_string, '', true ); } + + /** + * Helper method to set up WP_Site_Health instance with debug properties. + * + * @param bool $wp_debug Value for wp_debug property. + * @param bool $wp_debug_log Value for wp_debug_log property. + * @param bool|null $wp_debug_display Value for wp_debug_display property. + * + * @return WP_Site_Health + */ + private function setup_site_health_with_debug_properties( bool $wp_debug = false, bool $wp_debug_log = false, ?bool $wp_debug_display = null ) { + $site_health = new WP_Site_Health(); + $reflection = new ReflectionClass( $site_health ); + + $wp_debug_property = $reflection->getProperty( 'wp_debug' ); + $wp_debug_property->setAccessible( true ); + $wp_debug_property->setValue( $site_health, $wp_debug ); + + $wp_debug_log_property = $reflection->getProperty( 'wp_debug_log' ); + $wp_debug_log_property->setAccessible( true ); + $wp_debug_log_property->setValue( $site_health, $wp_debug_log ); + + $wp_debug_display_property = $reflection->getProperty( 'wp_debug_display' ); + $wp_debug_display_property->setAccessible( true ); + $wp_debug_display_property->setValue( $site_health, $wp_debug_display ); + + return $site_health; + } + + /** + * Helper method to set error_log ini setting and restore it later. + * + * @param string $log_path Path to set for error_log. + * + * @return string Original error_log value. + */ + private function set_error_log_path( string $log_path = '' ) { + $original_error_log = ini_get( 'error_log' ); + ini_set( 'error_log', $log_path ); + return $original_error_log; + } + + /** + * Helper method to restore error_log ini setting. + * + * @param string $original_value Original error_log value. + */ + private function restore_error_log_path( string $original_value = '' ) { + ini_set( 'error_log', $original_value ); + } + + /** + * Returns the expected result array when debug mode is disabled. + * + * @return array + */ + private function get_debug_mode_disabled_result() { + return array( + 'status' => 'good', + 'label' => 'Your site is not set to output debug information', + 'test' => 'is_in_debug_mode', + 'badge' => array( + 'label' => 'Security', + 'color' => 'blue', + ), + ); + } + + /** + * Returns the expected result array when debug log is in a public location. + * + * @param bool $wp_debug_log_defined Whether WP_DEBUG_LOG is defined. + * + * @return array + */ + private function get_debug_error_log_public_result( bool $wp_debug_log_defined = true ) { + + $result = array( + 'status' => 'critical', + 'label' => 'Your site is set to log errors to a potentially public file', + 'description' => 'The constant, WP_DEBUG_LOG, has been added to this website’s configuration file. This means any errors on the site will be written to a file which is likely publicly accessible.', + 'test' => 'is_in_debug_mode', + ); + + if ( ! $wp_debug_log_defined ) { + $result['description'] = 'The error log path has been configured to a file within the WordPress directory. This means any errors on the site will be written to a file which is likely publicly accessible.'; + } + + return $result; + } + + /** + * Returns the expected result array when debug log is in a private location. + * + * @param bool $wp_debug_log_defined Whether WP_DEBUG_LOG is defined. + * + * @return array + */ + private function get_debug_error_log_private_result( bool $wp_debug_log_defined = true ) { + + $result = array( + 'status' => 'good', + 'label' => 'Your site is set to log errors to a file outside the document root', + 'description' => 'The configuration constant, WP_DEBUG_LOG, is enabled. In addition, your site is set to write errors to a file outside the WordPress directory, which is a good practice as the log file should not be publicly accessible.', + 'test' => 'is_in_debug_mode', + ); + + if ( ! $wp_debug_log_defined ) { + $result['description'] = 'The error log path has been configured to a file outside the WordPress directory. This is a good practice as the log file should not be publicly accessible.'; + } + + return $result; + } + + /** + * Returns the expected result array when debug log path does not exist. + * + * @param bool $wp_debug_log_defined Whether WP_DEBUG_LOG is defined. + * + * @return array + */ + private function get_debug_log_non_existent_path_result( bool $wp_debug_log_defined = true ) { + + $result = array( + 'status' => 'critical', + 'label' => 'Unable to determine error log file location', + 'description' => 'The configuration constant, WP_DEBUG_LOG, is enabled, but the log file location could not be determined.', + 'test' => 'is_in_debug_mode', + ); + + if ( ! $wp_debug_log_defined ) { + $result['description'] = 'The error log path could not be determined. Please check your PHP configuration.'; + } + + return $result; + } + + /** + * Tests get_test_is_in_debug_mode() when debug mode is disabled. + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_disabled() { + $site_health = $this->setup_site_health_with_debug_properties( false, false, null ); + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_mode_disabled_result(); + + $this->assertSame( $expected_result['status'], $actual_result['status'], 'Status should be "good" when debug mode is disabled.' ); + $this->assertSame( $expected_result['label'], $actual_result['label'], 'Label should indicate debug mode is disabled.' ); + $this->assertSame( $expected_result['test'], $actual_result['test'], 'Test identifier should be "is_in_debug_mode".' ); + $this->assertArrayHasKey( 'badge', $actual_result, 'Result should have a badge.' ); + $this->assertSame( $expected_result['badge']['label'], $actual_result['badge']['label'], 'Badge label should be "Security".' ); + $this->assertSame( $expected_result['badge']['color'], $actual_result['badge']['color'], 'Badge color should be "blue".' ); + } + + /** + * Tests get_test_is_in_debug_mode() when WP_DEBUG is enabled without error logging. + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_enabled_no_error_log() { + $site_health = $this->setup_site_health_with_debug_properties( true, false, null ); + $original_error_log = $this->set_error_log_path( '' ); + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_mode_disabled_result(); + + $this->restore_error_log_path( $original_error_log ); + + $this->assertSame( $expected_result['status'], $actual_result['status'], 'Status should be "good" when no error log is configured.' ); + $this->assertSame( $expected_result['label'], $actual_result['label'], 'Label should indicate no error log is configured.' ); + $this->assertSame( $expected_result['test'], $actual_result['test'], 'Test identifier should be "is_in_debug_mode".' ); + $this->assertArrayHasKey( 'badge', $actual_result, 'Result should have a badge.' ); + } + + /** + * Tests get_test_is_in_debug_mode() when error log is in a public location. + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_public() { + $site_health = $this->setup_site_health_with_debug_properties( true, true, null ); + $public_log_path = ABSPATH . 'wp-content/debug.log'; + $original_error_log = $this->set_error_log_path( $public_log_path ); + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_error_log_public_result( true ); + + $this->restore_error_log_path( $original_error_log ); + + $this->assertSame( $expected_result['status'], $actual_result['status'], 'Status should be "critical" when error log is in a public location.' ); + $this->assertSame( $expected_result['label'], $actual_result['label'], 'Label should indicate error log is in a public location.' ); + $this->assertStringContainsString( $expected_result['description'], $actual_result['description'], 'Description should display error log is configured with WP_DEBUG_LOG and is in a public directory.' ); + } + + /** + * Tests get_test_is_in_debug_mode() when error log is public without WP_DEBUG_LOG. + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_public_without_wp_debug_log() { + $site_health = $this->setup_site_health_with_debug_properties( true, false, null ); + $public_log_path = ABSPATH . 'wp-content/debug.log'; + $original_error_log = $this->set_error_log_path( $public_log_path ); + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_error_log_public_result( false ); + + $this->restore_error_log_path( $original_error_log ); + + $this->assertSame( $expected_result['status'], $actual_result['status'], 'Status should be "critical" when error log is in a public location.' ); + $this->assertSame( $expected_result['label'], $actual_result['label'], 'Label should indicate error log is in a public location.' ); + $this->assertStringContainsString( $expected_result['description'], $actual_result['description'], 'Description should mention error log is configured without WP_DEBUG_LOG and in public directory.' ); + } + + /** + * Tests get_test_is_in_debug_mode() when error log is in a private location. + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_private() { + $site_health = $this->setup_site_health_with_debug_properties( true, true, null ); + $private_log_path = '/var/log/php-error.log'; + $original_error_log = $this->set_error_log_path( $private_log_path ); + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_error_log_private_result( true ); + + $this->restore_error_log_path( $original_error_log ); + + $this->assertSame( $expected_result['status'], $actual_result['status'], 'Status should be "good" when error log is in a private location.' ); + $this->assertSame( $expected_result['label'], $actual_result['label'], 'Label should indicate error log is in a private location.' ); + $this->assertStringContainsString( $expected_result['description'], $actual_result['description'], 'Description should mention error log is configured outside WordPress directory.' ); + } + + /** + * Tests get_test_is_in_debug_mode() when error log is private without WP_DEBUG_LOG. + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_private_without_wp_debug_log() { + $site_health = $this->setup_site_health_with_debug_properties( true, false, null ); + $private_log_path = '/var/log/php-error.log'; + $original_error_log = $this->set_error_log_path( $private_log_path ); + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_error_log_private_result( false ); + + $this->restore_error_log_path( $original_error_log ); + + $this->assertSame( $expected_result['status'], $actual_result['status'], 'Status should be "good" when error log is in a private location.' ); + $this->assertSame( $expected_result['label'], $actual_result['label'], 'Label should indicate error log is in a private location.' ); + $this->assertStringContainsString( $expected_result['description'], $actual_result['description'], 'Description should mention error log is configured outside WordPress directory.' ); + } + + /** + * Tests get_test_is_in_debug_mode() when error log path cannot be determined. + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_non_existent() { + $site_health = $this->setup_site_health_with_debug_properties( true, true, null ); + $invalid_log_path = '/nonexistent/path/that/does/not/exist/debug.log'; + $original_error_log = $this->set_error_log_path( $invalid_log_path ); + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_log_non_existent_path_result( true ); + + $this->restore_error_log_path( $original_error_log ); + + $this->assertSame( $expected_result['status'], $actual_result['status'], 'Status should be "critical" when error log location cannot be determined.' ); + $this->assertSame( $expected_result['label'], $actual_result['label'], 'Label should indicate that error log location could not be determined.' ); + $this->assertStringContainsString( $expected_result['description'], $actual_result['description'], 'Description should mention error log path is nonexistent.' ); + } + + /** + * Tests get_test_is_in_debug_mode() when error log path cannot be determined and WP_DEBUG_LOG is not defined. + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_non_existent_without_wp_debug_log() { + $site_health = $this->setup_site_health_with_debug_properties( true, false, null ); + $invalid_log_path = '/nonexistent/path/that/does/not/exist/debug.log'; + $original_error_log = $this->set_error_log_path( $invalid_log_path ); + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_log_non_existent_path_result( false ); + + $this->restore_error_log_path( $original_error_log ); + + $this->assertSame( $expected_result['status'], $actual_result['status'], 'Status should be "critical" when error log location cannot be determined.' ); + $this->assertSame( $expected_result['label'], $actual_result['label'], 'Label should indicate that error log location could not be determined.' ); + $this->assertStringContainsString( $expected_result['description'], $actual_result['description'], 'Description should mention error log path is nonexistent.' ); + } + + /** + * Tests get_test_is_in_debug_mode() when WP_DEBUG_DISPLAY is enabled in production. + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_display_enabled_production() { + $site_health_mock = $this->getMockBuilder( 'WP_Site_Health' ) + ->onlyMethods( array( 'is_development_environment' ) ) + ->getMock(); + + $site_health_mock->method( 'is_development_environment' ) + ->willReturn( false ); + + $site_health = new WP_Site_Health(); + $reflection_mock = new ReflectionClass( $site_health ); + + $wp_debug_property_mock = $reflection_mock->getProperty( 'wp_debug' ); + $wp_debug_property_mock->setAccessible( true ); + $wp_debug_property_mock->setValue( $site_health_mock, true ); + + $wp_debug_display_property_mock = $reflection_mock->getProperty( 'wp_debug_display' ); + $wp_debug_display_property_mock->setAccessible( true ); + $wp_debug_display_property_mock->setValue( $site_health_mock, true ); + + $actual_result = $site_health_mock->get_test_is_in_debug_mode(); + + $this->assertSame( 'critical', $actual_result['status'], 'Status should be "critical" when WP_DEBUG_DISPLAY is enabled in production.' ); + $this->assertSame( 'Your site is set to display errors to site visitors', $actual_result['label'], 'Label should indicate that errors are displayed to visitors.' ); + $this->assertStringContainsString( 'WP_DEBUG_DISPLAY', $actual_result['description'], 'Description should contain WP_DEBUG_DISPLAY.' ); + } + + /** + * Tests get_test_is_in_debug_mode() when WP_DEBUG_DISPLAY is enabled in development. + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_display_enabled_development() { + $site_health_mock = $this->getMockBuilder( 'WP_Site_Health' ) + ->onlyMethods( array( 'is_development_environment' ) ) + ->getMock(); + + $site_health_mock->method( 'is_development_environment' ) + ->willReturn( true ); + + $site_health = new WP_Site_Health(); + $reflection_mock = new ReflectionClass( $site_health ); + + $wp_debug_property_mock = $reflection_mock->getProperty( 'wp_debug' ); + $wp_debug_property_mock->setAccessible( true ); + $wp_debug_property_mock->setValue( $site_health_mock, true ); + + $wp_debug_display_property_mock = $reflection_mock->getProperty( 'wp_debug_display' ); + $wp_debug_display_property_mock->setAccessible( true ); + $wp_debug_display_property_mock->setValue( $site_health_mock, true ); + + $actual_result = $site_health_mock->get_test_is_in_debug_mode(); + + $this->assertSame( 'recommended', $actual_result['status'], 'Status should be "recommended" when WP_DEBUG_DISPLAY is enabled in development.' ); + $this->assertSame( 'Your site is set to display errors to site visitors', $actual_result['label'], 'Label should indicate that errors are displayed to visitors.' ); + $this->assertStringContainsString( 'WP_DEBUG_DISPLAY', $actual_result['description'], 'Description should contain WP_DEBUG_DISPLAY.' ); + } + + /** + * Tests get_test_is_in_debug_mode() validates actual_result structure. + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_result_structure() { + $site_health = new WP_Site_Health(); + $actual_result = $site_health->get_test_is_in_debug_mode(); + + $this->assertArrayHasKey( 'label', $actual_result, 'Result should have a label.' ); + $this->assertArrayHasKey( 'status', $actual_result, 'Result should have a status.' ); + $this->assertArrayHasKey( 'badge', $actual_result, 'Result should have a badge.' ); + $this->assertArrayHasKey( 'description', $actual_result, 'Result should have a description.' ); + $this->assertArrayHasKey( 'actions', $actual_result, 'Result should have actions.' ); + $this->assertArrayHasKey( 'test', $actual_result, 'Result should have a test identifier.' ); + $this->assertIsArray( $actual_result['badge'], 'Badge should be an array.' ); + $this->assertArrayHasKey( 'label', $actual_result['badge'], 'Badge should have a label.' ); + $this->assertArrayHasKey( 'color', $actual_result['badge'], 'Badge should have a color.' ); + $this->assertContains( $actual_result['status'], array( 'good', 'recommended', 'critical' ), 'Status should be one of: good, recommended, critical.' ); + } }