diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 27073205d1..8d5accde89 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -14,6 +14,7 @@ * Initializes extensions for Optimization Detective. * * @since 0.7.0 + * @access private */ function od_initialize_extensions(): void { /** @@ -29,6 +30,9 @@ function od_initialize_extensions(): void { /** * Generates a media query for the provided minimum and maximum viewport widths. * + * This helper function is available for extensions to leverage when manually printing STYLE rules via + * {@see OD_HTML_Tag_Processor::append_head_html()} or {@see OD_HTML_Tag_Processor::append_body_html()} + * * @since 0.7.0 * * @param int|null $minimum_viewport_width Minimum viewport width. @@ -59,16 +63,25 @@ function od_generate_media_query( ?int $minimum_viewport_width, ?int $maximum_vi * See {@see 'wp_head'}. * * @since 0.1.0 + * @access private */ function od_render_generator_meta_tag(): void { // Use the plugin slug as it is immutable. - echo '' . "\n"; + $content = 'optimization-detective ' . OPTIMIZATION_DETECTIVE_VERSION; + + // Indicate that the plugin will not be doing anything because the REST API is unavailable. + if ( od_is_rest_api_unavailable() ) { + $content .= '; rest_api_unavailable'; + } + + echo '' . "\n"; } /** * Gets the path to a script or stylesheet. * * @since 0.9.0 + * @access private * * @param string $src_path Source path, relative to plugin root. * @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path. diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index c0f94d148c..0ada1214f5 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_add_rest_api_availability_test' ); +add_action( 'admin_init', 'od_maybe_run_rest_api_health_check' ); +add_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row', 30 ); diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 81b60cb75f..6253bb388e 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -127,5 +127,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 cb3539c672..081df2e17d 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 + if ( + ! od_can_optimize_response() || + od_is_rest_api_unavailable() || + isset( $_GET['optimization_detective_disabled'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + ) { 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 0000000000..13da11d595 --- /dev/null +++ b/plugins/optimization-detective/site-health.php @@ -0,0 +1,293 @@ +}|mixed $tests Site Health Tests. + * @return array{direct: array} Amended tests. + */ +function od_add_rest_api_availability_test( $tests ): array { + if ( ! is_array( $tests ) ) { + $tests = array(); + } + $tests['direct']['optimization_detective_rest_api'] = array( + 'label' => __( 'Optimization Detective REST API Endpoint Availability', 'optimization-detective' ), + 'test' => static function () { + // Note: A closure is used here to improve symbol discovery for the sake of potential refactoring. + return od_test_rest_api_availability(); + }, + ); + + return $tests; +} + +/** + * Tests availability of the Optimization Detective REST API endpoint. + * + * @since n.e.x.t + * @access private + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Result. + */ +function od_test_rest_api_availability(): array { + $response = od_get_rest_api_health_check_response( false ); + $result = od_compose_site_health_result( $response ); + $is_unavailable = 'good' !== $result['status']; + update_option( + 'od_rest_api_unavailable', + $is_unavailable ? '1' : '0', + true // Intentionally autoloaded since used on every frontend request. + ); + return $result; +} + +/** + * Checks whether the Optimization Detective REST API endpoint is unavailable. + * + * This merely checks the database option what was previously computed in the Site Health test as done in {@see od_test_rest_api_availability()}. + * This is to avoid checking for REST API availability during a frontend request. Note that when the plugin is first + * installed, the 'od_rest_api_unavailable' option will not be in the database, as the check has not been performed + * yet. Once Site Health's weekly check happens or when a user accesses the admin so that the admin_init action fires, + * then at this point the check will be performed at {@see od_maybe_run_rest_api_health_check()}. In practice, this will + * happen immediately after the user activates a plugin since the user is redirected back to the plugin list table in + * the admin. The reason for storing the negative unavailable state as opposed to the positive available state is that + * when an option does not exist then `get_option()` returns `false` which is the same falsy value as the stored `'0'`. + * + * @since n.e.x.t + * @access private + * + * @return bool Whether unavailable. + */ +function od_is_rest_api_unavailable(): bool { + return 1 === (int) get_option( 'od_rest_api_unavailable', '0' ); +} + +/** + * Tests availability of the Optimization Detective REST API endpoint. + * + * @since n.e.x.t + * @access private + * + * @param array|WP_Error $response REST API response. + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Result. + */ +function od_compose_site_health_result( $response ): array { + $common_description_html = '

' . wp_kses( + sprintf( + /* translators: %s is the REST API endpoint */ + __( 'To collect URL Metrics from visitors the REST API must be available to unauthenticated users. Specifically, visitors must be able to perform a POST request to the %s endpoint.', 'optimization-detective' ), + '/' . OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE + ), + array( 'code' => array() ) + ) . '

'; + + $result = array( + 'label' => __( 'The Optimization Detective REST API endpoint is available', 'optimization-detective' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Optimization Detective', 'optimization-detective' ), + 'color' => 'blue', + ), + 'description' => $common_description_html . '

' . esc_html__( 'This appears to be working properly.', 'optimization-detective' ) . '

', + 'actions' => '', + 'test' => 'optimization_detective_rest_api', + ); + + $error_label = __( 'The Optimization Detective REST API endpoint is unavailable', 'optimization-detective' ); + $error_description_html = '

' . esc_html__( 'You may have a plugin active or server configuration which restricts access to logged-in users. Unauthenticated access must be restored in order for Optimization Detective to work.', 'optimization-detective' ) . '

'; + + if ( is_wp_error( $response ) ) { + $result['status'] = 'recommended'; + $result['label'] = $error_label; + $result['description'] = $common_description_html . $error_description_html . '

' . wp_kses( + sprintf( + /* translators: %s is the error code */ + __( 'The REST API responded with the error code %s and the following error message:', 'optimization-detective' ), + esc_html( (string) $response->get_error_code() ) + ), + array( 'code' => array() ) + ) . '

' . esc_html( $response->get_error_message() ) . '
'; + } else { + $code = wp_remote_retrieve_response_code( $response ); + $message = wp_remote_retrieve_response_message( $response ); + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + $is_expected = ( + 400 === $code && + isset( $data['code'], $data['data']['params'] ) && + 'rest_missing_callback_param' === $data['code'] && + is_array( $data['data']['params'] ) && + count( $data['data']['params'] ) > 0 + ); + if ( ! $is_expected ) { + $result['status'] = 'recommended'; + if ( 401 === $code ) { + $result['label'] = __( 'The Optimization Detective REST API endpoint is unavailable to logged-out users', 'optimization-detective' ); + } else { + $result['label'] = $error_label; + } + $result['description'] = $common_description_html . $error_description_html . '

' . wp_kses( + sprintf( + /* translators: %d is the HTTP status code, %s is the status header description */ + __( 'The REST API returned with an HTTP status of %1$d %2$s.', 'optimization-detective' ), + $code, + esc_html( $message ) + ), + array( 'code' => array() ) + ) . '

'; + + if ( isset( $data['message'] ) && is_string( $data['message'] ) ) { + $result['description'] .= '
' . esc_html( $data['message'] ) . '
'; + } + + $result['description'] .= '
' . esc_html__( 'Raw response:', 'optimization-detective' ) . '
' . esc_html( $body ) . '
'; + } + } + return $result; +} + +/** + * Gets the response to an Optimization Detective REST API store request to confirm it is available to unauthenticated requests. + * + * @since n.e.x.t + * @access private + * + * @param bool $use_cached Whether to use a previous response cached in a transient. + * @return array{ response: array{ code: int, message: string }, body: string }|WP_Error Response. + */ +function od_get_rest_api_health_check_response( bool $use_cached ) { + $transient_key = 'od_rest_api_health_check_response'; + $response = $use_cached ? get_transient( $transient_key ) : false; + if ( false !== $response ) { + return $response; + } + $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, + ) + ); + + // This transient will be used when showing the admin notice with the plugin on the plugins screen. + // The 1-day expiration allows for fresher content than the weekly check initiated by Site Health. + set_transient( $transient_key, $response, DAY_IN_SECONDS ); + return $response; +} + +/** + * Renders an admin notice if the REST API health check fails. + * + * @since n.e.x.t + * @access private + * + * @param bool $in_plugin_row Whether the notice is to be printed in the plugin row. + */ +function od_maybe_render_rest_api_health_check_admin_notice( bool $in_plugin_row ): void { + if ( ! od_is_rest_api_unavailable() ) { + return; + } + + $response = od_get_rest_api_health_check_response( true ); + $result = od_compose_site_health_result( $response ); + if ( 'good' === $result['status'] ) { + // There's a slight chance the DB option is stale in the initial if statement. + return; + } + + $message = sprintf( + $in_plugin_row + ? '%s %s' + : '

%s %s

', + esc_html__( 'Warning:', 'optimization-detective' ), + esc_html( $result['label'] ) + ); + + $message .= $result['description']; // This has already gone through Kses. + + if ( current_user_can( 'view_site_health_checks' ) ) { + $site_health_message = wp_kses( + sprintf( + /* translators: %s is the URL to the Site Health admin screen */ + __( 'Please visit Site Health to re-check this once you believe you have resolved the issue.', 'optimization-detective' ), + esc_url( admin_url( 'site-health.php' ) ) + ), + array( 'a' => array( 'href' => array() ) ) + ); + $message .= "

$site_health_message

"; + } + + if ( $in_plugin_row ) { + $message = "
$message
"; + } + + wp_admin_notice( + $message, + array( + 'type' => 'warning', + 'additional_classes' => $in_plugin_row ? array( 'inline', 'notice-alt' ) : array(), + 'paragraph_wrap' => false, + ) + ); +} + +/** + * Displays an admin notice on the plugin row if the REST API health check fails. + * + * @since n.e.x.t + * @access private + * + * @param string $plugin_file Plugin file. + */ +function od_render_rest_api_health_check_admin_notice_in_plugin_row( string $plugin_file ): void { + if ( 'optimization-detective/load.php' !== $plugin_file ) { // TODO: What if a different plugin slug is used? + return; + } + od_maybe_render_rest_api_health_check_admin_notice( true ); +} + +/** + * Runs the REST API health check if it hasn't been run yet. + * + * This happens at the `admin_init` action to avoid running the check on the frontend. This will run on the first admin + * page load after the plugin has been activated. This allows for this function to add an action at `admin_notices` so + * that an error message can be displayed after performing that plugin activation request. Note that a plugin activation + * hook cannot be used for this purpose due to not being compatible with multisite. While the site health notice is + * shown at the `admin_notices` action once, the notice will only be displayed inline with the plugin row thereafter + * via {@see od_render_rest_api_health_check_admin_notice_in_plugin_row()}. + * + * @since n.e.x.t + * @access private + */ +function od_maybe_run_rest_api_health_check(): void { + // If the option already exists, then the REST API health check has already been performed. + if ( false !== get_option( 'od_rest_api_unavailable' ) ) { + return; + } + + // This will populate the od_rest_api_unavailable option so that the function won't execute on the next page load. + if ( 'good' !== od_test_rest_api_availability()['status'] ) { + // Show any notice in the main admin notices area for the first page load (e.g. after plugin activation). + add_action( + 'admin_notices', + static function (): void { + od_maybe_render_rest_api_health_check_admin_notice( false ); + } + ); + } +} diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 09ce02501e..ce278a9f57 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -34,6 +34,8 @@ * * @since 0.1.0 * @access private + * + * @see od_compose_site_health_result() */ function od_register_endpoint(): void { diff --git a/plugins/optimization-detective/tests/test-helper.php b/plugins/optimization-detective/tests/test-helper.php index 42bcf62dd8..3d9ae9c5ad 100644 --- a/plugins/optimization-detective/tests/test-helper.php +++ b/plugins/optimization-detective/tests/test-helper.php @@ -93,5 +93,22 @@ public function test_od_render_generator_meta_tag(): void { $this->assertStringStartsWith( 'assertStringContainsString( 'generator', $tag ); $this->assertStringContainsString( 'optimization-detective ' . OPTIMIZATION_DETECTIVE_VERSION, $tag ); + $this->assertFalse( od_is_rest_api_unavailable() ); + $this->assertStringNotContainsString( 'rest_api_unavailable', $tag ); + } + + /** + * Test printing the meta generator tag when the REST API is not available. + * + * @covers ::od_render_generator_meta_tag + */ + public function test_od_render_generator_meta_tag_rest_api_unavailable(): void { + update_option( 'od_rest_api_unavailable', '1' ); + $tag = get_echo( 'od_render_generator_meta_tag' ); + $this->assertStringStartsWith( 'assertStringContainsString( 'generator', $tag ); + $this->assertStringContainsString( 'optimization-detective ' . OPTIMIZATION_DETECTIVE_VERSION, $tag ); + $this->assertTrue( od_is_rest_api_unavailable() ); + $this->assertStringContainsString( '; rest_api_unavailable', $tag ); } } diff --git a/plugins/optimization-detective/tests/test-hooks.php b/plugins/optimization-detective/tests/test-hooks.php index 7c53ea49d2..aa5d71ffc6 100644 --- a/plugins/optimization-detective/tests/test-hooks.php +++ b/plugins/optimization-detective/tests/test-hooks.php @@ -26,5 +26,8 @@ public function test_hooks_added(): void { ) ); $this->assertEquals( 10, has_action( 'wp_head', 'od_render_generator_meta_tag' ) ); + $this->assertEquals( 10, has_filter( 'site_status_tests', 'od_add_rest_api_availability_test' ) ); + $this->assertEquals( 10, has_action( 'admin_init', 'od_maybe_run_rest_api_health_check' ) ); + $this->assertEquals( 30, has_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row' ) ); } } diff --git a/plugins/optimization-detective/tests/test-optimization.php b/plugins/optimization-detective/tests/test-optimization.php index c0247b3508..879221c1a8 100644 --- a/plugins/optimization-detective/tests/test-optimization.php +++ b/plugins/optimization-detective/tests/test-optimization.php @@ -145,37 +145,74 @@ function ( $buffer ) use ( $template_start, $template_middle, $template_end, &$f } /** - * Test od_maybe_add_template_output_buffer_filter(). + * Data provider. * - * @covers ::od_maybe_add_template_output_buffer_filter + * @return array */ - public function test_od_maybe_add_template_output_buffer_filter(): void { - $this->assertFalse( has_filter( 'od_template_output_buffer' ) ); - - add_filter( 'od_can_optimize_response', '__return_false', 1 ); - od_maybe_add_template_output_buffer_filter(); - $this->assertFalse( od_can_optimize_response() ); - $this->assertFalse( has_filter( 'od_template_output_buffer' ) ); - - add_filter( 'od_can_optimize_response', '__return_true', 2 ); - $this->go_to( home_url( '/' ) ); - $this->assertTrue( od_can_optimize_response() ); - od_maybe_add_template_output_buffer_filter(); - $this->assertTrue( has_filter( 'od_template_output_buffer' ) ); + public function data_provider_test_od_maybe_add_template_output_buffer_filter(): array { + return array( + 'home_enabled' => array( + 'set_up' => static function (): string { + return home_url( '/' ); + }, + 'expected_has_filter' => true, + ), + 'home_disabled_by_filter' => array( + 'set_up' => static function (): string { + add_filter( 'od_can_optimize_response', '__return_false' ); + return home_url( '/' ); + }, + 'expected_has_filter' => false, + ), + 'search_disabled' => array( + 'set_up' => static function (): string { + return home_url( '/?s=foo' ); + }, + 'expected_has_filter' => false, + ), + 'search_enabled_by_filter' => array( + 'set_up' => static function (): string { + add_filter( 'od_can_optimize_response', '__return_true' ); + return home_url( '/?s=foo' ); + }, + 'expected_has_filter' => true, + ), + 'home_disabled_by_get_param' => array( + 'set_up' => static function (): string { + return home_url( '/?optimization_detective_disabled=1' ); + }, + 'expected_has_filter' => false, + ), + 'home_disabled_by_rest_api_unavailable' => array( + 'set_up' => static function (): string { + update_option( 'od_rest_api_unavailable', '1' ); + return home_url( '/' ); + }, + 'expected_has_filter' => false, + ), + ); } + /** * Test od_maybe_add_template_output_buffer_filter(). * + * @dataProvider data_provider_test_od_maybe_add_template_output_buffer_filter + * * @covers ::od_maybe_add_template_output_buffer_filter + * @covers ::od_can_optimize_response + * @covers ::od_is_rest_api_unavailable */ - public function test_od_maybe_add_template_output_buffer_filter_with_query_var_to_disable(): void { - $this->assertFalse( has_filter( 'od_template_output_buffer' ) ); + public function test_od_maybe_add_template_output_buffer_filter( Closure $set_up, bool $expected_has_filter ): void { + // There needs to be a post so that there is a post in the loop so that od_get_cache_purge_post_id() returns a post ID. + // Otherwise, od_can_optimize_response() will return false unless forced by a filter. + self::factory()->post->create(); + + $url = $set_up(); + $this->go_to( $url ); + remove_all_filters( 'od_template_output_buffer' ); // In case go_to() caused them to be added. - add_filter( 'od_can_optimize_response', '__return_true' ); - $this->go_to( home_url( '/?optimization_detective_disabled=1' ) ); - $this->assertTrue( od_can_optimize_response() ); od_maybe_add_template_output_buffer_filter(); - $this->assertFalse( has_filter( 'od_template_output_buffer' ) ); + $this->assertSame( $expected_has_filter, has_filter( 'od_template_output_buffer' ) ); } /** @@ -188,66 +225,78 @@ public function data_provider_test_od_can_optimize_response(): array { 'home_as_anonymous' => array( 'set_up' => function (): void { $this->go_to( home_url( '/' ) ); + $this->assertIsInt( od_get_cache_purge_post_id() ); }, 'expected' => true, ), - 'home_filtered_as_anonymous' => array( + 'home_but_no_posts' => array( 'set_up' => function (): void { + $posts = get_posts(); + foreach ( $posts as $post ) { + wp_delete_post( $post->ID, true ); + } $this->go_to( home_url( '/' ) ); + $this->assertNull( od_get_cache_purge_post_id() ); + }, + 'expected' => false, // This is because od_get_cache_purge_post_id() will return false. + ), + 'home_filtered_as_anonymous' => array( + 'set_up' => static function (): string { add_filter( 'od_can_optimize_response', '__return_false' ); + return home_url( '/' ); }, 'expected' => false, ), 'singular_as_anonymous' => array( - 'set_up' => function (): void { + 'set_up' => function (): string { $posts = get_posts(); $this->assertInstanceOf( WP_Post::class, $posts[0] ); - $this->go_to( get_permalink( $posts[0] ) ); + return get_permalink( $posts[0] ); }, 'expected' => true, ), 'search_as_anonymous' => array( - 'set_up' => function (): void { + 'set_up' => static function (): string { self::factory()->post->create( array( 'post_title' => 'Hello' ) ); - $this->go_to( home_url( '?s=Hello' ) ); + return home_url( '?s=Hello' ); }, 'expected' => false, ), 'home_customizer_preview_as_anonymous' => array( - 'set_up' => function (): void { - $this->go_to( home_url( '/' ) ); + 'set_up' => static function (): string { global $wp_customize; require_once ABSPATH . 'wp-includes/class-wp-customize-manager.php'; $wp_customize = new WP_Customize_Manager(); $wp_customize->start_previewing_theme(); + return home_url( '/' ); }, 'expected' => false, ), 'home_post_request_as_anonymous' => array( - 'set_up' => function (): void { - $this->go_to( home_url( '/' ) ); + 'set_up' => static function (): string { $_SERVER['REQUEST_METHOD'] = 'POST'; + return home_url( '/' ); }, 'expected' => false, ), 'home_as_subscriber' => array( - 'set_up' => function (): void { + 'set_up' => static function (): string { wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); - $this->go_to( home_url( '/' ) ); + return home_url( '/' ); }, 'expected' => true, ), 'empty_author_page_as_anonymous' => array( - 'set_up' => function (): void { + 'set_up' => static function (): string { $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); - $this->go_to( get_author_posts_url( $user_id ) ); + return get_author_posts_url( $user_id ); }, 'expected' => false, ), 'home_as_admin' => array( - 'set_up' => function (): void { + 'set_up' => static function (): string { wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); - $this->go_to( home_url( '/' ) ); + return home_url( '/' ); }, 'expected' => true, ), @@ -263,8 +312,12 @@ public function data_provider_test_od_can_optimize_response(): array { * @dataProvider data_provider_test_od_can_optimize_response */ public function test_od_can_optimize_response( Closure $set_up, bool $expected ): void { - self::factory()->post->create(); // Make sure there is at least one post in the DB. - $set_up(); + // Make sure there is at least one post in the DB as otherwise od_get_cache_purge_post_id() will return false, + // causing od_can_optimize_response() to return false. + self::factory()->post->create(); + + $url = $set_up(); + $this->go_to( $url ); $this->assertSame( $expected, od_can_optimize_response() ); } diff --git a/plugins/optimization-detective/tests/test-site-health.php b/plugins/optimization-detective/tests/test-site-health.php new file mode 100644 index 0000000000..8ca97fd51f --- /dev/null +++ b/plugins/optimization-detective/tests/test-site-health.php @@ -0,0 +1,411 @@ + 'rest_missing_callback_param', + 'message' => 'Missing parameter(s): slug, current_etag, hmac, url, viewport, elements', + 'data' => array( + 'status' => 400, + 'params' => array( + 'slug', + 'current_etag', + 'hmac', + 'url', + 'viewport', + 'elements', + ), + ), + ), + ); + + const UNAUTHORISED_MOCKED_RESPONSE_ARGS = array( + 401, + 'Unauthorized', + array( + 'code' => 'unauthorized_without_message', + ), + ); + + const FORBIDDEN_MOCKED_RESPONSE_ARGS = array( + 403, + 'Forbidden', + array( + 'code' => 'rest_login_required', + 'message' => 'REST API restricted to authenticated users.', + 'data' => array( 'status' => 401 ), + ), + ); + + /** + * @covers ::od_add_rest_api_availability_test + */ + public function test_od_add_rest_api_availability_test(): void { + $initial_tests = array( + 'direct' => array( + 'foo' => array( + 'label' => 'Foo', + 'test' => 'foo_test', + ), + ), + ); + + $tests = od_add_rest_api_availability_test( + $initial_tests + ); + $this->assertCount( 2, $tests['direct'] ); + $this->assertArrayHasKey( 'foo', $tests['direct'] ); + $this->assertSame( $initial_tests['direct']['foo'], $tests['direct']['foo'] ); + $this->assertArrayHasKey( 'optimization_detective_rest_api', $tests['direct'] ); + $this->assertArrayHasKey( 'label', $tests['direct']['optimization_detective_rest_api'] ); + $this->assertArrayHasKey( 'test', $tests['direct']['optimization_detective_rest_api'] ); + $this->assertTrue( is_callable( $tests['direct']['optimization_detective_rest_api']['test'] ) ); + $this->filter_rest_api_response( $this->build_mock_response( ...self::EXPECTED_MOCKED_RESPONSE_ARGS ) ); + $result = call_user_func( $tests['direct']['optimization_detective_rest_api']['test'] ); + $this->assertSame( 'good', $result['status'] ); + + $tests = od_add_rest_api_availability_test( + new WP_Error() + ); + $this->assertCount( 1, $tests['direct'] ); + $this->assertArrayHasKey( 'optimization_detective_rest_api', $tests['direct'] ); + } + + /** + * Test that we presume the REST API is accessible before we are able to perform the Site Health check. + * + * @covers ::od_is_rest_api_unavailable + */ + public function test_rest_api_assumed_accessible(): void { + $this->assertFalse( get_option( 'od_rest_api_unavailable', false ) ); + $this->assertFalse( od_is_rest_api_unavailable() ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_test_rest_api_availability(): array { + return array( + 'available' => array( + 'mocked_response' => $this->build_mock_response( ...self::EXPECTED_MOCKED_RESPONSE_ARGS ), + 'expected_option' => '0', + 'expected_status' => 'good', + 'expected_unavailable' => false, + ), + 'unauthorized' => array( + 'mocked_response' => $this->build_mock_response( ...self::UNAUTHORISED_MOCKED_RESPONSE_ARGS ), + 'expected_option' => '1', + 'expected_status' => 'recommended', + 'expected_unavailable' => true, + ), + 'forbidden' => array( + 'mocked_response' => $this->build_mock_response( ...self::FORBIDDEN_MOCKED_RESPONSE_ARGS ), + 'expected_option' => '1', + 'expected_status' => 'recommended', + 'expected_unavailable' => true, + ), + 'nginx_forbidden' => array( + 'mocked_response' => array( + 'response' => array( + 'code' => 403, + 'message' => 'Forbidden', + ), + 'body' => "\n403 Forbidden\n\n

403 Forbidden

\n
nginx
\n\n", + ), + 'expected_option' => '1', + 'expected_status' => 'recommended', + 'expected_unavailable' => true, + ), + 'error' => array( + 'mocked_response' => new WP_Error( 'bad', 'Something terrible has happened' ), + 'expected_option' => '1', + 'expected_status' => 'recommended', + 'expected_unavailable' => true, + ), + ); + } + + /** + * Test various conditions for the REST API being available. + * + * @covers ::od_test_rest_api_availability + * @covers ::od_compose_site_health_result + * @covers ::od_get_rest_api_health_check_response + * @covers ::od_is_rest_api_unavailable + * + * @dataProvider data_provider_test_rest_api_availability + * + * @phpstan-param array|WP_Error $mocked_response + */ + public function test_rest_api_availability( $mocked_response, string $expected_option, string $expected_status, bool $expected_unavailable ): void { + $this->filter_rest_api_response( $mocked_response ); + + $result = od_test_rest_api_availability(); + $this->assertArrayHasKey( 'label', $result ); + $this->assertArrayHasKey( 'status', $result ); + $this->assertArrayHasKey( 'badge', $result ); + $this->assertArrayHasKey( 'description', $result ); + $this->assertArrayHasKey( 'test', $result ); + $this->assertSame( $expected_option, get_option( 'od_rest_api_unavailable', '' ) ); + $this->assertArrayHasKey( 'od_rest_api_unavailable', wp_load_alloptions(), 'Expected option to be autoloaded.' ); + $this->assertSame( $expected_status, $result['status'] ); + $this->assertSame( $expected_unavailable, od_is_rest_api_unavailable() ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_test_od_get_rest_api_health_check_response(): array { + return array( + 'available' => array( + 'mocked_response' => $this->build_mock_response( ...self::EXPECTED_MOCKED_RESPONSE_ARGS ), + ), + 'unauthorized' => array( + 'mocked_response' => $this->build_mock_response( ...self::UNAUTHORISED_MOCKED_RESPONSE_ARGS ), + ), + 'error' => array( + 'mocked_response' => new WP_Error( 'bad' ), + ), + ); + } + + /** + * @covers ::od_get_rest_api_health_check_response + * + * @dataProvider data_provider_test_od_get_rest_api_health_check_response + * + * @param array|WP_Error $mocked_response Mocked response. + */ + public function test_od_get_rest_api_health_check_response( $mocked_response ): void { + $transient_key = 'od_rest_api_health_check_response'; + $filter_observer = $this->filter_rest_api_response( $mocked_response ); + delete_transient( $transient_key ); + $this->assertSame( 0, $filter_observer->counter ); + + $response = od_get_rest_api_health_check_response( false ); + $this->assertEquals( $mocked_response, get_transient( $transient_key ) ); + $this->assertEquals( $mocked_response, $response ); + $this->assertSame( 1, $filter_observer->counter ); + + $response = od_get_rest_api_health_check_response( false ); + $this->assertEquals( $mocked_response, get_transient( $transient_key ) ); + $this->assertEquals( $mocked_response, $response ); + $this->assertSame( 2, $filter_observer->counter ); + + $response = od_get_rest_api_health_check_response( true ); + $this->assertEquals( $mocked_response, get_transient( $transient_key ) ); + $this->assertEquals( $mocked_response, $response ); + $this->assertSame( 2, $filter_observer->counter ); + + delete_transient( $transient_key ); + $response = od_get_rest_api_health_check_response( true ); + $this->assertEquals( $mocked_response, get_transient( $transient_key ) ); + $this->assertEquals( $mocked_response, $response ); + $this->assertSame( 3, $filter_observer->counter ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_in_plugin_row(): array { + return array( + 'in_admin_notices' => array( + 'in_plugin_row' => false, + ), + 'in_plugin_row' => array( + 'in_plugin_row' => true, + ), + ); + } + + /** + * Initial state when there is no option set yet. + * + * @dataProvider data_provider_in_plugin_row + * @covers ::od_maybe_render_rest_api_health_check_admin_notice + */ + public function test_od_maybe_render_rest_api_health_check_admin_notice_no_option_set( bool $in_plugin_row ): void { + $this->assertFalse( od_is_rest_api_unavailable() ); + $this->assertSame( '', get_echo( 'od_maybe_render_rest_api_health_check_admin_notice', array( $in_plugin_row ) ) ); + } + + /** + * When the REST API works as expected. + * + * @dataProvider data_provider_in_plugin_row + * @covers ::od_maybe_render_rest_api_health_check_admin_notice + */ + public function test_od_maybe_render_rest_api_health_check_admin_notice_rest_api_available( bool $in_plugin_row ): void { + $this->filter_rest_api_response( $this->build_mock_response( ...self::EXPECTED_MOCKED_RESPONSE_ARGS ) ); + od_test_rest_api_availability(); + $this->assertFalse( od_is_rest_api_unavailable() ); + $this->assertSame( '', get_echo( 'od_maybe_render_rest_api_health_check_admin_notice', array( $in_plugin_row ) ) ); + } + + /** + * When the REST API is not available. + * + * @dataProvider data_provider_in_plugin_row + * @covers ::od_maybe_render_rest_api_health_check_admin_notice + */ + public function test_od_maybe_render_rest_api_health_check_admin_notice_rest_api_not_available( bool $in_plugin_row ): void { + $super_admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + grant_super_admin( $super_admin_user_id ); // Since Site Health is only available to super admins. + wp_set_current_user( $super_admin_user_id ); + + $this->filter_rest_api_response( $this->build_mock_response( ...self::UNAUTHORISED_MOCKED_RESPONSE_ARGS ) ); + od_test_rest_api_availability(); + $this->assertTrue( od_is_rest_api_unavailable() ); + $notice = get_echo( 'od_maybe_render_rest_api_health_check_admin_notice', array( $in_plugin_row ) ); + $this->assertStringContainsString( '
assertStringContainsString( '
', $notice ); + $this->assertStringContainsString( '', $notice ); + $this->assertStringNotContainsString( '

', $notice ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_test_od_maybe_run_rest_api_health_check(): array { + return array( + 'option_absent_and_rest_api_available' => array( + 'set_up' => function (): void { + delete_option( 'od_rest_api_unavailable' ); + $this->filter_rest_api_response( $this->build_mock_response( ...self::EXPECTED_MOCKED_RESPONSE_ARGS ) ); + }, + 'expected' => false, + ), + 'option_present_and_cached_available' => array( + 'set_up' => static function (): void { + update_option( 'od_rest_api_unavailable', '0' ); + }, + 'expected' => false, + ), + 'rest_api_unavailable' => array( + 'set_up' => function (): void { + delete_option( 'od_rest_api_unavailable' ); + $this->filter_rest_api_response( $this->build_mock_response( ...self::UNAUTHORISED_MOCKED_RESPONSE_ARGS ) ); + }, + 'expected' => true, + ), + ); + } + + /** + * @dataProvider data_provider_test_od_maybe_run_rest_api_health_check + * + * @covers ::od_maybe_run_rest_api_health_check + */ + public function test_od_maybe_run_rest_api_health( Closure $set_up, bool $expected ): void { + remove_all_actions( 'admin_notices' ); + $set_up(); + od_maybe_run_rest_api_health_check(); + $this->assertSame( $expected, (bool) has_action( 'admin_notices' ) ); + $admin_notices_output = get_echo( 'do_action', array( 'admin_notices' ) ); + if ( $expected ) { + $this->assertStringContainsString( '

', $admin_notices_output ); + } else { + $this->assertStringNotContainsString( '
', $admin_notices_output ); + } + } + + /** + * Filters REST API response with mock. + * + * @param array|WP_Error $mocked_response Mocked response. + * @return object{ counter: int } Value which contains a counter for the number of times the filter applied. + */ + protected function filter_rest_api_response( $mocked_response ): object { + $observer = (object) array( 'counter' => 0 ); + remove_all_filters( 'pre_http_request' ); + add_filter( + 'pre_http_request', + static function ( $pre, array $args, string $url ) use ( $mocked_response, $observer ) { + if ( rest_url( OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ) === $url ) { + $observer->counter++; + return $mocked_response; + } + return $pre; + }, + 10, + 3 + ); + return $observer; + } + + /** + * 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 6202323d9b..f5e360c6b3 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_unavailable' ); + delete_transient( 'od_rest_api_health_check_response' ); }; $od_delete_site_data();