diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 9eb4c8525a942..2e2c825d159d6 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -29,6 +29,22 @@ class WP_Site_Health { private $timeout_missed_cron = null; private $timeout_late_cron = null; + /** + * @since 7.0.0 + */ + private bool $wp_debug; + + /** + * @since 7.0.0 + */ + private ?bool $wp_debug_display; + + /** + * @since 7.0.0 + * @var string|bool + */ + private $wp_debug_log; + /** * WP_Site_Health constructor. * @@ -54,6 +70,18 @@ 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; + if ( defined( 'WP_DEBUG_LOG' ) && ( is_bool( WP_DEBUG_LOG ) || is_string( WP_DEBUG_LOG ) ) ) { + $this->wp_debug_log = WP_DEBUG_LOG; + } else { + $this->wp_debug_log = false; + } + if ( defined( 'WP_DEBUG_DISPLAY' ) && is_bool( WP_DEBUG_DISPLAY ) ) { + $this->wp_debug_display = WP_DEBUG_DISPLAY; + } else { + $this->wp_debug_display = null; + } } /** @@ -1393,7 +1421,17 @@ public function get_test_dotorg_communication() { * * @since 5.2.0 * - * @return array The test results. + * @return array{ + * label: string, + * status: string, + * badge: array{ + * label: string, + * color: string + * }, + * description: string, + * actions: string, + * test: string + * } The test results. */ public function get_test_is_in_debug_mode() { $result = array( @@ -1418,23 +1456,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/phpstan/bootstrap.php b/tests/phpstan/bootstrap.php index 6eedeec93c4a7..6b504e53eeead 100644 --- a/tests/phpstan/bootstrap.php +++ b/tests/phpstan/bootstrap.php @@ -22,7 +22,14 @@ define( 'WP_DEVELOPMENT_MODE', '' ); define( 'WP_DEBUG', false ); define( 'WP_DEBUG_DISPLAY', false ); -define( 'WP_DEBUG_LOG', false ); +/* + * WP_DEBUG_LOG can be either boolean or a string path. Use a union-typed variable so + * PHPStan understands both possibilities when analysing core. + * + * @var bool|string $wp_debug_log + */ +$wp_debug_log = false; +define( 'WP_DEBUG_LOG', $wp_debug_log ); define( 'WP_CACHE', false ); define( 'SCRIPT_DEBUG', false ); define( 'MEDIA_TRASH', false ); diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 6080b477f54c3..935576da60c64 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -15,6 +15,14 @@ class Tests_Admin_wpSiteHealth extends WP_UnitTestCase { */ private WP_Site_Health $instance; + /** + * Original error_log ini setting, restored after each test. + * + * @since 7.0.0 + * @var string|false + */ + private $original_error_log; + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { // Include the `WP_Site_Health` file. require_once ABSPATH . 'wp-admin/includes/class-wp-site-health.php'; @@ -28,7 +36,18 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { public function set_up() { parent::set_up(); - $this->instance = new WP_Site_Health(); + $this->instance = new WP_Site_Health(); + $this->original_error_log = ini_get( 'error_log' ); + } + + /** + * Tear down after each test. + * + * @since 7.0.0 + */ + public function tear_down() { + ini_set( 'error_log', $this->original_error_log ); + parent::tear_down(); } /** @@ -707,4 +726,398 @@ 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'] ); } } + + /** + * Helper method to set up WP_Site_Health instance with debug properties. + * + * @param bool $wp_debug Value for wp_debug property. + * @param bool|string $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, $wp_debug_log = false, ?bool $wp_debug_display = null ): WP_Site_Health { + $site_health = new WP_Site_Health(); + $reflection = new ReflectionClass( $site_health ); + + $wp_debug_property = $reflection->getProperty( 'wp_debug' ); + if ( PHP_VERSION_ID < 80100 ) { + $wp_debug_property->setAccessible( true ); + } + $wp_debug_property->setValue( $site_health, $wp_debug ); + + $wp_debug_log_property = $reflection->getProperty( 'wp_debug_log' ); + if ( PHP_VERSION_ID < 80100 ) { + $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' ); + if ( PHP_VERSION_ID < 80100 ) { + $wp_debug_display_property->setAccessible( true ); + } + $wp_debug_display_property->setValue( $site_health, $wp_debug_display ); + + return $site_health; + } + + /** + * Returns the expected result array when debug mode is disabled. + * + * @return array> + */ + private function get_debug_mode_disabled_result(): array { + 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 ): array { + + $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 ): array { + + $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 ): array { + + $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. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_disabled(): void { + $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. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_enabled_no_error_log(): void { + $site_health = $this->setup_site_health_with_debug_properties( true, false, null ); + + ini_set( 'error_log', '' ); + + $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 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. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_public(): void { + $site_health = $this->setup_site_health_with_debug_properties( true, true, null ); + $public_log_path = ABSPATH . 'wp-content/debug.log'; + + ini_set( 'error_log', $public_log_path ); + + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_error_log_public_result( true ); + + $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. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_public_without_wp_debug_log(): void { + $site_health = $this->setup_site_health_with_debug_properties( true, false, null ); + $public_log_path = ABSPATH . 'wp-content/debug.log'; + + ini_set( 'error_log', $public_log_path ); + + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_error_log_public_result( false ); + + $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. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_private(): void { + $site_health = $this->setup_site_health_with_debug_properties( true, true, null ); + $private_log_path = '/var/log/php-error.log'; + + ini_set( 'error_log', $private_log_path ); + + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_error_log_private_result( true ); + + $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. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_private_without_wp_debug_log(): void { + $site_health = $this->setup_site_health_with_debug_properties( true, false, null ); + $private_log_path = '/var/log/php-error.log'; + + ini_set( 'error_log', $private_log_path ); + + $actual_result = $site_health->get_test_is_in_debug_mode(); + $expected_result = $this->get_debug_error_log_private_result( false ); + + $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. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_non_existent(): void { + $site_health = $this->setup_site_health_with_debug_properties( true, true, null ); + $invalid_log_path = '/nonexistent/path/that/does/not/exist/debug.log'; + + ini_set( 'error_log', $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->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. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_error_log_non_existent_without_wp_debug_log(): void { + $site_health = $this->setup_site_health_with_debug_properties( true, false, null ); + $invalid_log_path = '/nonexistent/path/that/does/not/exist/debug.log'; + + ini_set( 'error_log', $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->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 explicitly disabled. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_display_disabled(): void { + $site_health = $this->setup_site_health_with_debug_properties( true, true, false ); + $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 WP_DEBUG_DISPLAY is explicitly false.' ); + $this->assertSame( $expected_result['label'], $actual_result['label'], 'Label should indicate debug mode is disabled.' ); + } + + /** + * Tests get_test_is_in_debug_mode() when WP_DEBUG_DISPLAY is enabled in production. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_display_enabled_production(): void { + $site_health_mock = $this->getMockBuilder( 'WP_Site_Health' ) + ->onlyMethods( array( 'is_development_environment' ) ) + ->getMock(); + + $site_health_mock->method( 'is_development_environment' ) + ->willReturn( false ); + + $reflection = new ReflectionClass( WP_Site_Health::class ); + + $wp_debug_property_mock = $reflection->getProperty( 'wp_debug' ); + if ( PHP_VERSION_ID < 80100 ) { + $wp_debug_property_mock->setAccessible( true ); + } + $wp_debug_property_mock->setValue( $site_health_mock, true ); + + $wp_debug_display_property_mock = $reflection->getProperty( 'wp_debug_display' ); + if ( PHP_VERSION_ID < 80100 ) { + $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. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_display_enabled_development(): void { + $site_health_mock = $this->getMockBuilder( 'WP_Site_Health' ) + ->onlyMethods( array( 'is_development_environment' ) ) + ->getMock(); + + $site_health_mock->method( 'is_development_environment' ) + ->willReturn( true ); + + $reflection = new ReflectionClass( WP_Site_Health::class ); + + $wp_debug_property_mock = $reflection->getProperty( 'wp_debug' ); + if ( PHP_VERSION_ID < 80100 ) { + $wp_debug_property_mock->setAccessible( true ); + } + $wp_debug_property_mock->setValue( $site_health_mock, true ); + + $wp_debug_display_property_mock = $reflection->getProperty( 'wp_debug_display' ); + if ( PHP_VERSION_ID < 80100 ) { + $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. + * + * @ticket 64071 + * + * @covers ::get_test_is_in_debug_mode() + */ + public function test_is_in_debug_mode_result_structure(): void { + $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.' ); + } }