diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index c0f94d148..13b1d390b 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -15,3 +15,6 @@ OD_URL_Metrics_Post_Type::add_hooks(); add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' ); add_action( 'wp_head', 'od_render_generator_meta_tag' ); +add_filter( 'site_status_tests', 'od_optimization_detective_add_rest_api_test' ); +add_action( 'od_rest_api_health_check_event', 'od_run_scheduled_rest_api_health_check' ); +add_action( 'after_plugin_row_meta', 'od_rest_api_health_check_admin_notice', 30 ); diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 81b60cb75..f536ac3e9 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -51,6 +51,18 @@ static function ( string $global_var_name, string $version, Closure $load ): voi * action), so this is why it gets initialized at priority 9. */ add_action( 'init', $bootstrap, 9 ); + + register_activation_hook( + __FILE__, + static function () use ( $bootstrap ): void { + /* + * The activation hook is called before the init action, so the plugin is not loaded yet. This + * means that the plugin must be bootstrapped here to run the activation logic. + */ + $bootstrap(); + od_rest_api_health_check_plugin_activation(); + } + ); } // Register this copy of the plugin. @@ -127,5 +139,8 @@ class_alias( OD_URL_Metric_Group_Collection::class, 'OD_URL_Metrics_Group_Collec // Add hooks for the above requires. require_once __DIR__ . '/hooks.php'; + + // Load site health checks. + require_once __DIR__ . '/site-health.php'; } ); diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index fa72a6194..a843d17c3 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -78,7 +78,12 @@ static function ( string $output, ?int $phase ): string { * @access private */ function od_maybe_add_template_output_buffer_filter(): void { - if ( ! od_can_optimize_response() || isset( $_GET['optimization_detective_disabled'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $od_rest_api_info = get_option( 'od_rest_api_info' ); + if ( + ! od_can_optimize_response() || + isset( $_GET['optimization_detective_disabled'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ( isset( $od_rest_api_info['available'] ) && ! (bool) $od_rest_api_info['available'] ) + ) { return; } $callback = 'od_optimize_template_output_buffer'; diff --git a/plugins/optimization-detective/site-health.php b/plugins/optimization-detective/site-health.php new file mode 100644 index 000000000..fa9b49ad5 --- /dev/null +++ b/plugins/optimization-detective/site-health.php @@ -0,0 +1,188 @@ +} $tests Site Health Tests. + * @return array{direct: array} Amended tests. + */ +function od_optimization_detective_add_rest_api_test( array $tests ): array { + $tests['direct']['optimization_detective_rest_api'] = array( + 'label' => __( 'Optimization Detective REST API Endpoint Availability', 'optimization-detective' ), + 'test' => 'od_optimization_detective_rest_api_test', + ); + + return $tests; +} + +/** + * Tests availability of the Optimization Detective REST API endpoint. + * + * @since n.e.x.t + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Result. + */ +function od_optimization_detective_rest_api_test(): array { + $result = array( + 'label' => __( 'The REST API endpoint is functional.', 'optimization-detective' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Optimization Detective', 'optimization-detective' ), + 'color' => 'blue', + ), + 'description' => sprintf( + '

%s

', + __( 'Your site can send and receive URL metrics via the REST API endpoint.', 'optimization-detective' ) + ), + 'actions' => '', + 'test' => 'optimization_detective_rest_api', + ); + + $rest_url = get_rest_url( null, OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ); + $response = wp_remote_post( + $rest_url, + array( + 'headers' => array( 'Content-Type' => 'application/json' ), + 'sslverify' => false, + ) + ); + + if ( is_wp_error( $response ) ) { + $result['status'] = 'recommended'; + $result['label'] = __( 'Error accessing the REST API endpoint', 'optimization-detective' ); + $result['description'] = sprintf( + '

%s

', + esc_html__( 'There was an issue reaching the REST API endpoint. This might be due to server settings or the REST API being disabled.', 'optimization-detective' ) + ); + $info = array( + 'error_message' => $response->get_error_message(), + 'error_code' => $response->get_error_code(), + 'available' => false, + ); + } else { + $status_code = wp_remote_retrieve_response_code( $response ); + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + $expected_params = array( 'slug', 'current_etag', 'hmac', 'url', 'viewport', 'elements' ); + $info = array( + 'status_code' => $status_code, + 'available' => false, + ); + + if ( + 400 === $status_code + && isset( $data['data']['params'] ) + && is_array( $data['data']['params'] ) + && count( $expected_params ) === count( array_intersect( $data['data']['params'], $expected_params ) ) + ) { + // The REST API endpoint is available. + $info['available'] = true; + } elseif ( 401 === $status_code ) { + $result['status'] = 'recommended'; + $result['label'] = __( 'Authorization should not be required to access the REST API endpoint.', 'optimization-detective' ); + $result['description'] = sprintf( + '

%s

', + esc_html__( 'To collect URL metrics, the REST API endpoint should be accessible without requiring authorization.', 'optimization-detective' ) + ); + } elseif ( 403 === $status_code ) { + $result['status'] = 'recommended'; + $result['label'] = __( 'The REST API endpoint should not be forbidden.', 'optimization-detective' ); + $result['description'] = sprintf( + '

%s

', + esc_html__( 'The REST API endpoint is blocked. Please review your server or security settings.', 'optimization-detective' ) + ); + } else { + $result['status'] = 'recommended'; + $result['label'] = __( 'Error accessing the REST API endpoint', 'optimization-detective' ); + $result['description'] = sprintf( + '

%s

', + esc_html__( 'There was an issue reaching the REST API endpoint. This might be due to server settings or the REST API being disabled.', 'optimization-detective' ) + ); + } + $info['error_message'] = $result['label']; + } + + update_option( 'od_rest_api_info', $info ); + return $result; +} + +/** + * Periodically runs the Optimization Detective REST API health check. + * + * @since n.e.x.t + */ +function od_schedule_rest_api_health_check(): void { + if ( ! (bool) wp_next_scheduled( 'od_rest_api_health_check_event' ) ) { + wp_schedule_event( time(), 'weekly', 'od_rest_api_health_check_event' ); + } +} + +/** + * Hook for the scheduled REST API health check. + * + * @since n.e.x.t + */ +function od_run_scheduled_rest_api_health_check(): void { + od_optimization_detective_rest_api_test(); +} + +/** + * Displays an admin notice if the REST API health check fails. + * + * @since n.e.x.t + * + * @param string $plugin_file Plugin file. + */ +function od_rest_api_health_check_admin_notice( string $plugin_file ): void { + if ( 'optimization-detective/load.php' !== $plugin_file ) { + return; + } + + $od_rest_api_info = get_option( 'od_rest_api_info', array() ); + if ( + isset( $od_rest_api_info['available'] ) && + ! (bool) $od_rest_api_info['available'] && + isset( $od_rest_api_info['error_message'] ) + ) { + wp_admin_notice( + esc_html( $od_rest_api_info['error_message'] ), + array( + 'type' => 'warning', + 'additional_classes' => array( 'inline', 'notice-alt' ), + ) + ); + } +} + +/** + * Plugin activation hook for the REST API health check. + * + * @since n.e.x.t + */ +function od_rest_api_health_check_plugin_activation(): void { + // Add the option if it doesn't exist. + if ( ! (bool) get_option( 'od_rest_api_info' ) ) { + add_option( 'od_rest_api_info', array() ); + } + od_schedule_rest_api_health_check(); + // Run the check immediately after Optimization Detective is activated. + add_action( + 'activated_plugin', + static function ( string $plugin ): void { + if ( 'optimization-detective/load.php' === $plugin ) { + od_optimization_detective_rest_api_test(); + } + } + ); +} diff --git a/plugins/optimization-detective/tests/test-optimization-detective-rest-api-site-health-check.php b/plugins/optimization-detective/tests/test-optimization-detective-rest-api-site-health-check.php new file mode 100644 index 000000000..cc5adeed5 --- /dev/null +++ b/plugins/optimization-detective/tests/test-optimization-detective-rest-api-site-health-check.php @@ -0,0 +1,131 @@ +> + */ + protected $mocked_responses = array(); + + /** + * Setup each test. + */ + public function setUp(): void { + parent::setUp(); + + // Clear any filters or mocks. + remove_all_filters( 'pre_http_request' ); + + // Add the filter to mock HTTP requests. + add_filter( 'pre_http_request', array( $this, 'mock_http_requests' ), 10, 3 ); + } + + /** + * Test that the site health check is `good` when the REST API is available. + */ + public function test_rest_api_available(): void { + $this->mocked_responses = array( + get_rest_url( null, OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ) => $this->build_mock_response( + 400, + 'Bad Request', + array( + 'data' => array( + 'params' => array( 'slug', 'current_etag', 'hmac', 'url', 'viewport', 'elements' ), + ), + ) + ), + ); + + $result = od_optimization_detective_rest_api_test(); + $od_rest_api_info = get_option( 'od_rest_api_info', array() ); + + $this->assertSame( 'good', $result['status'] ); + $this->assertSame( 400, isset( $od_rest_api_info['status_code'] ) ? $od_rest_api_info['status_code'] : '' ); + $this->assertTrue( isset( $od_rest_api_info['available'] ) ? $od_rest_api_info['available'] : false ); + } + + /** + * Test behavior when REST API returns an unauthorized error. + */ + public function test_rest_api_unauthorized(): void { + $this->mocked_responses = array( + get_rest_url( null, OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ) => $this->build_mock_response( + 401, + 'Unauthorized' + ), + ); + + $result = od_optimization_detective_rest_api_test(); + $od_rest_api_info = get_option( 'od_rest_api_info', array() ); + + $this->assertSame( 'recommended', $result['status'] ); + $this->assertSame( 401, isset( $od_rest_api_info['status_code'] ) ? $od_rest_api_info['status_code'] : '' ); + $this->assertFalse( isset( $od_rest_api_info['available'] ) ? $od_rest_api_info['available'] : true ); + } + + /** + * Test behavior when REST API returns an forbidden error. + */ + public function test_rest_api_forbidden(): void { + $this->mocked_responses = array( + get_rest_url( null, OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ) => $this->build_mock_response( + 403, + 'Forbidden' + ), + ); + + $result = od_optimization_detective_rest_api_test(); + $od_rest_api_info = get_option( 'od_rest_api_info', array() ); + + $this->assertSame( 'recommended', $result['status'] ); + $this->assertSame( 403, isset( $od_rest_api_info['status_code'] ) ? $od_rest_api_info['status_code'] : '' ); + $this->assertFalse( isset( $od_rest_api_info['available'] ) ? $od_rest_api_info['available'] : true ); + } + + /** + * Mock HTTP requests for assets to simulate different responses. + * + * @param bool $response A preemptive return value of an HTTP request. Default false. + * @param array $args Request arguments. + * @param string $url The request URL. + * @return array Mocked response. + */ + public function mock_http_requests( bool $response, array $args, string $url ): array { + if ( isset( $this->mocked_responses[ $url ] ) ) { + return $this->mocked_responses[ $url ]; + } + + // If no specific mock set, default to a generic success response. + return array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + } + + /** + * Build a mock response. + * + * @param int $status_code HTTP status code. + * @param string $message HTTP status message. + * @param array $body Response body. + * @return array Mocked response. + */ + protected function build_mock_response( int $status_code, string $message, array $body = array() ): array { + return array( + 'response' => array( + 'code' => $status_code, + 'message' => $message, + ), + 'body' => wp_json_encode( $body ), + ); + } +} diff --git a/plugins/optimization-detective/uninstall.php b/plugins/optimization-detective/uninstall.php index 6202323d9..a646ed011 100644 --- a/plugins/optimization-detective/uninstall.php +++ b/plugins/optimization-detective/uninstall.php @@ -17,6 +17,10 @@ // Delete all URL Metrics posts for the current site. OD_URL_Metrics_Post_Type::delete_all_posts(); wp_unschedule_hook( OD_URL_Metrics_Post_Type::GC_CRON_EVENT_NAME ); + + // Clear out site health check data. + delete_option( 'od_rest_api_info' ); + wp_unschedule_hook( 'od_rest_api_health_check_event' ); }; $od_delete_site_data();