Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.77% covered (warning)
67.77%
267 / 394
47.62% covered (danger)
47.62%
10 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Connection_Health_Tests
68.11% covered (warning)
68.11%
267 / 392
47.62% covered (danger)
47.62%
10 / 21
462.48
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 test__blog_token_if_exists
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 test__check_if_connected
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
4
 test__master_user_exists_on_site
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
4
 test__master_user_can_manage_options
71.43% covered (warning)
71.43%
15 / 21
0.00% covered (danger)
0.00%
0 / 1
4.37
 test__identity_crisis
82.35% covered (warning)
82.35%
28 / 34
0.00% covered (danger)
0.00%
0 / 1
10.55
 check_identity_crisis
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 test__connection_token_health
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
7.77
 check_blog_token_health
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 check_tokens_health
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 test__wpcom_connection_test
32.73% covered (danger)
32.73%
18 / 55
0.00% covered (danger)
0.00%
0 / 1
40.45
 evaluate_wpcom_connection_result
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
14
 report_connection_state_error
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 clear_blocked_request_error
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 clear_ssl_verification_error
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 blocked_request_failing_test
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 ssl_verification_failing_test
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 connection_state_failing_test
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 test__server_port_value
89.47% covered (warning)
89.47%
34 / 38
0.00% covered (danger)
0.00%
0 / 1
11.14
 test__xml_parser_available
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
3.69
 last__wpcom_self_test
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2/**
3 * Collection of health tests for the Jetpack Connection.
4 *
5 * @package automattic/jetpack-connection
6 */
7
8namespace Automattic\Jetpack\Connection;
9
10use Automattic\Jetpack\Constants;
11use Automattic\Jetpack\Identity_Crisis;
12use Automattic\Jetpack\Redirect;
13use Automattic\Jetpack\Status;
14use Jetpack_Options;
15
16if ( ! defined( 'ABSPATH' ) ) {
17    exit( 0 );
18}
19
20/**
21 * Class Connection_Health_Tests contains all connection-specific health tests.
22 *
23 * @since 8.5.0
24 */
25class Connection_Health_Tests extends Connection_Health_Test_Base {
26
27    /**
28     * Connection_Health_Tests constructor.
29     */
30    public function __construct() {
31        parent::__construct();
32
33        $methods = get_class_methods( static::class );
34
35        foreach ( $methods as $method ) {
36            if ( ! str_contains( $method, 'test__' ) ) {
37                continue;
38            }
39            $this->add_test( array( $this, $method ), $method, 'direct' );
40        }
41
42        /**
43         * Fires after loading default connection health tests.
44         *
45         * Allows other packages or plugins to register additional tests.
46         *
47         * @since 7.1.0
48         * @since 8.3.0 Passes the test suite instance.
49         * @since 8.5.0 Moved from Jetpack_Cxn_Tests to Connection_Health_Tests.
50         *
51         * @param Connection_Health_Tests $this The Connection_Health_Tests instance.
52         */
53        do_action( 'jetpack_connection_tests_loaded', $this );
54
55        /**
56         * Determines if the WP.com testing suite should be included.
57         *
58         * @since 7.1.0
59         * @since 8.1.0 Default false.
60         *
61         * @param bool $run_test To run the WP.com testing suite. Default false.
62         */
63        if ( apply_filters( 'jetpack_debugger_run_self_test', false ) ) {
64            $this->add_test( array( $this, 'last__wpcom_self_test' ), 'test__wpcom_self_test', 'direct' );
65        }
66    }
67
68    /**
69     * The test verifies the blog token exists.
70     *
71     * @return array
72     */
73    protected function test__blog_token_if_exists() {
74        $name = 'test__blog_token_if_exists';
75
76        if ( ! $this->helper_is_connected() ) {
77            return self::skipped_test(
78                array(
79                    'name'              => $name,
80                    'short_description' => __( 'Your site is not connected to WordPress.com. No site token to check.', 'jetpack-connection' ),
81                )
82            );
83        }
84        $blog_token = $this->helper_get_blog_token();
85
86        if ( $blog_token ) {
87            return self::passing_test( array( 'name' => $name ) );
88        }
89
90        return self::connection_failing_test( $name, __( 'The site token used to authenticate with WordPress.com is missing.', 'jetpack-connection' ) );
91    }
92
93    /**
94     * Test if Jetpack is connected.
95     *
96     * @return array
97     */
98    protected function test__check_if_connected() {
99        $name = 'test__check_if_connected';
100
101        if ( ! $this->helper_get_blog_token() ) {
102            return self::skipped_test(
103                array(
104                    'name'              => $name,
105                    'short_description' => __( 'The site token used to authenticate with WordPress.com is missing.', 'jetpack-connection' ),
106                )
107            );
108        }
109
110        if ( $this->helper_is_connected() ) {
111            return self::passing_test(
112                array(
113                    'name'             => $name,
114                    'label'            => __( 'Your site is connected to WordPress.com', 'jetpack-connection' ),
115                    'long_description' => sprintf(
116                        '<p>%1$s</p>' .
117                        '<p><span class="dashicons pass"><span class="screen-reader-text">%2$s</span></span> %3$s</p>',
118                        self::helper_get_healthy_connection_text(),
119                        /* translators: Screen reader text indicating a test has passed */
120                        __( 'Passed', 'jetpack-connection' ),
121                        __( 'Your site is connected to WordPress.com.', 'jetpack-connection' )
122                    ),
123                )
124            );
125        } elseif ( ( new Status() )->is_offline_mode() ) {
126            return self::skipped_test(
127                array(
128                    'name'              => $name,
129                    'short_description' => __( 'Your site is in Offline Mode.', 'jetpack-connection' ),
130                )
131            );
132        }
133
134        return self::connection_failing_test( $name, __( 'Your site is not connected to WordPress.com', 'jetpack-connection' ) );
135    }
136
137    /**
138     * Test that the connection owner still exists on this site.
139     *
140     * @return array
141     */
142    protected function test__master_user_exists_on_site() {
143        $name = 'test__master_user_exists_on_site';
144
145        if ( ! $this->helper_is_connected() ) {
146            return self::skipped_test(
147                array(
148                    'name'              => $name,
149                    'short_description' => __( 'Your site is not connected to WordPress.com. No connection owner to check.', 'jetpack-connection' ),
150                )
151            );
152        }
153        if ( ! ( new Manager() )->get_connection_owner_id() ) {
154            return self::skipped_test(
155                array(
156                    'name'              => $name,
157                    'short_description' => __( 'The site is connected to WordPress.com without a user. No connection owner to check.', 'jetpack-connection' ),
158                )
159            );
160        }
161        $local_user = $this->helper_retrieve_connection_owner();
162
163        if ( $local_user->exists() ) {
164            return self::passing_test( array( 'name' => $name ) );
165        }
166
167        return self::connection_failing_test(
168            $name,
169            __( 'The user who set up the Jetpack Connection no longer exists on this site.', 'jetpack-connection' )
170        );
171    }
172
173    /**
174     * Test that the connection owner has the manage options capability (e.g. is an admin).
175     *
176     * @return array
177     */
178    protected function test__master_user_can_manage_options() {
179        $name = 'test__master_user_can_manage_options';
180
181        if ( ! $this->helper_is_connected() ) {
182            return self::skipped_test(
183                array(
184                    'name'              => $name,
185                    'short_description' => __( 'Your site is not connected to WordPress.com.', 'jetpack-connection' ),
186                )
187            );
188        }
189        if ( ! ( new Manager() )->get_connection_owner_id() ) {
190            return self::skipped_test(
191                array(
192                    'name'              => $name,
193                    'short_description' => __( 'The site is connected to WordPress.com without a user. No connection owner to check.', 'jetpack-connection' ),
194                )
195            );
196        }
197        $owner_user = $this->helper_retrieve_connection_owner();
198
199        if ( user_can( $owner_user, 'manage_options' ) ) {
200            return self::passing_test( array( 'name' => $name ) );
201        }
202
203        /* translators: a WordPress username */
204        $connection_error = sprintf( __( 'The user (%s) who set up the Jetpack Connection is not an administrator.', 'jetpack-connection' ), $owner_user->user_login );
205        /* translators: a WordPress username */
206        $recommendation = sprintf( __( 'We recommend either upgrading the user (%s) or reconnecting your site to WordPress.com.', 'jetpack-connection' ), $owner_user->user_login );
207
208        return self::connection_failing_test( $name, $connection_error, $recommendation );
209    }
210
211    /**
212     * Check for an Identity Crisis.
213     *
214     * @return array
215     */
216    protected function test__identity_crisis() {
217        $name = 'test__identity_crisis';
218
219        if ( ! $this->helper_is_connected() ) {
220            return self::skipped_test(
221                array(
222                    'name'              => $name,
223                    'short_description' => __( 'Your site is not connected to WordPress.com.', 'jetpack-connection' ),
224                )
225            );
226        }
227
228        $identity_crisis = $this->check_identity_crisis();
229
230        if ( ! $identity_crisis ) {
231            return self::passing_test( array( 'name' => $name ) );
232        }
233
234        $messages = array();
235
236        if ( isset( $identity_crisis['home'] ) && isset( $identity_crisis['wpcom_home'] ) && $identity_crisis['home'] !== $identity_crisis['wpcom_home'] ) {
237            $messages[] = sprintf(
238                /* translators: Two URLs. The first is the locally-recorded value, the second is the value as recorded on WP.com. */
239                __( 'Your home URL is set as `%1$s`, but your Jetpack Connection lists it as `%2$s`.', 'jetpack-connection' ),
240                $identity_crisis['home'],
241                $identity_crisis['wpcom_home']
242            );
243        }
244
245        if ( isset( $identity_crisis['siteurl'] ) && isset( $identity_crisis['wpcom_siteurl'] ) && $identity_crisis['siteurl'] !== $identity_crisis['wpcom_siteurl'] ) {
246            $messages[] = sprintf(
247                /* translators: Two URLs. The first is the locally-recorded value, the second is the value as recorded on WP.com. */
248                __( 'Your site URL is set as `%1$s`, but your Jetpack Connection lists it as `%2$s`.', 'jetpack-connection' ),
249                $identity_crisis['siteurl'],
250                $identity_crisis['wpcom_siteurl']
251            );
252        }
253
254        if ( empty( $messages ) ) {
255            $messages[] = __( 'A URL mismatch was detected between your site and WordPress.com.', 'jetpack-connection' );
256        }
257
258        return self::failing_test(
259            array(
260                'name'              => $name,
261                'short_description' => implode( ' ', $messages ),
262                'action_label'      => $this->helper_get_support_text(),
263                'action'            => $this->helper_get_support_url(),
264            )
265        );
266    }
267
268    /**
269     * Check for Identity Crisis using connection package classes.
270     *
271     * @return array|false False if no IDC, array with crisis details otherwise.
272     */
273    protected function check_identity_crisis() {
274        if ( ! ( new Manager() )->is_connected() || ( new Status() )->is_offline_mode() ) {
275            return false;
276        }
277
278        if ( ! class_exists( 'Automattic\Jetpack\Identity_Crisis' ) || ! Identity_Crisis::validate_sync_error_idc_option() ) {
279            return false;
280        }
281
282        return Jetpack_Options::get_option( 'sync_error_idc' );
283    }
284
285    /**
286     * Tests the health of the connection tokens.
287     *
288     * @return array
289     */
290    protected function test__connection_token_health() {
291        $name    = 'test__connection_token_health';
292        $m       = new Manager();
293        $user_id = get_current_user_id();
294
295        // Check if there's a connected logged in user.
296        if ( $user_id && ! $m->is_user_connected( $user_id ) ) {
297            $user_id = false;
298        }
299
300        // If no logged in user to check, let's see if there's a connection owner set.
301        if ( ! $user_id ) {
302            $user_id = Jetpack_Options::get_option( 'master_user' );
303            if ( $user_id && ! $m->is_user_connected( $user_id ) ) {
304                return self::connection_failing_test( $name, __( 'Missing token for the connection owner.', 'jetpack-connection' ) );
305            }
306        }
307
308        if ( $user_id ) {
309            return $this->check_tokens_health( $user_id );
310        }
311
312        return $this->check_blog_token_health();
313    }
314
315    /**
316     * Tests blog token against WP.com's check-token-health endpoint.
317     *
318     * @return array
319     */
320    protected function check_blog_token_health() {
321        $name  = 'test__connection_token_health';
322        $valid = ( new Tokens() )->validate_blog_token();
323
324        // A WP_Error, meaning the check could not run, is truthy.
325        if ( true !== $valid ) {
326            return self::connection_failing_test( $name, __( 'The site token used to authenticate with WordPress.com could not be validated.', 'jetpack-connection' ) );
327        }
328
329        return self::passing_test( array( 'name' => $name ) );
330    }
331
332    /**
333     * Tests blog and user tokens against WP.com's check-token-health endpoint.
334     *
335     * @param int $user_id The user ID to check the tokens for.
336     *
337     * @return array
338     */
339    protected function check_tokens_health( $user_id ) {
340        $name             = 'test__connection_token_health';
341        $validated_tokens = ( new Tokens() )->validate( $user_id );
342
343        if ( ! is_array( $validated_tokens ) || count( array_diff_key( array_flip( array( 'blog_token', 'user_token' ) ), $validated_tokens ) ) ) {
344            return self::skipped_test(
345                array(
346                    'name'              => $name,
347                    'short_description' => __( 'Token health check failed to validate tokens.', 'jetpack-connection' ),
348                )
349            );
350        }
351
352        $invalid_tokens_exist = false;
353        foreach ( $validated_tokens as $validated_token ) {
354            if ( ! $validated_token['is_healthy'] ) {
355                $invalid_tokens_exist = true;
356                break;
357            }
358        }
359
360        if ( ! $invalid_tokens_exist ) {
361            return self::passing_test( array( 'name' => $name ) );
362        }
363
364        return self::connection_failing_test( $name, __( 'Invalid Jetpack Connection tokens.', 'jetpack-connection' ) );
365    }
366
367    /**
368     * Tests connection status against WP.com's test-connection endpoint.
369     *
370     * @return array
371     */
372    protected function test__wpcom_connection_test() {
373        $name = 'test__wpcom_connection_test';
374
375        $status      = new Status();
376        $skip_reason = '';
377
378        if ( $status->is_offline_mode() ) {
379            $skip_reason = __( 'Your site is in Offline Mode, so this test was skipped.', 'jetpack-connection' );
380        } elseif ( $status->in_safe_mode() ) {
381            $skip_reason = __( 'Your site is in Safe Mode, so this test was skipped.', 'jetpack-connection' );
382        } elseif ( ! ( new Manager() )->is_connected() ) {
383            $skip_reason = __( 'Your site is not communicating with WordPress.com, so this test was skipped.', 'jetpack-connection' );
384        } elseif ( ! $this->pass ) {
385            $skip_reason = __( 'A previous connection health test failed, so this test was skipped.', 'jetpack-connection' );
386        }
387
388        if ( $skip_reason ) {
389            return self::skipped_test(
390                array(
391                    'name'              => $name,
392                    'short_description' => $skip_reason,
393                )
394            );
395        }
396
397        add_filter( 'http_request_timeout', array( static::class, 'increase_timeout' ) );
398        $response = Client::wpcom_json_api_request_as_blog(
399            sprintf( '/jetpack-blogs/%d/test-connection', Jetpack_Options::get_option( 'id' ) ),
400            Client::WPCOM_JSON_API_VERSION
401        );
402        remove_filter( 'http_request_timeout', array( static::class, 'increase_timeout' ) );
403
404        if ( is_wp_error( $response ) ) {
405            if ( str_contains( $response->get_error_message(), 'cURL error 28' ) ) {
406                return self::skipped_test(
407                    array(
408                        'name'              => $name,
409                        'short_description' => self::helper_get_timeout_text(),
410                    )
411                );
412            }
413
414            /* translators: %1$s is the error code, %2$s is the error message */
415            $message = sprintf( __( 'Connection test failed (#%1$s: %2$s)', 'jetpack-connection' ), $response->get_error_code(), $response->get_error_message() );
416            return self::connection_failing_test( $name, $message );
417        }
418
419        $body = wp_remote_retrieve_body( $response );
420        if ( ! $body ) {
421            return self::failing_test(
422                array(
423                    'name'              => $name,
424                    'short_description' => sprintf(
425                        /* translators: %s is the HTTP status code returned by WordPress.com. */
426                        __( 'Connection test failed: WordPress.com returned an empty response (status code: %s).', 'jetpack-connection' ),
427                        wp_remote_retrieve_response_code( $response )
428                    ),
429                    'action_label'      => $this->helper_get_support_text(),
430                    'action'            => $this->helper_get_support_url(),
431                )
432            );
433        }
434
435        if ( 404 === wp_remote_retrieve_response_code( $response ) ) {
436            return self::skipped_test(
437                array(
438                    'name'              => $name,
439                    'short_description' => __( 'The WordPress.com API returned a 404 error.', 'jetpack-connection' ),
440                )
441            );
442        }
443
444        return $this->evaluate_wpcom_connection_result( $name, json_decode( $body ), wp_remote_retrieve_response_code( $response ) );
445    }
446
447    /**
448     * Turns a decoded WP.com test-connection response into a test result.
449     *
450     * Split out from test__wpcom_connection_test() so the decision logic can be
451     * exercised without performing a signed remote request.
452     *
453     * Besides producing the Site Health result, this also keeps the Error_Handler
454     * state for `xmlrpc_request_blocked` and `wpcom_ssl_verification_failed` in sync: a
455     * result carrying one of those codes reports the matching error (making it
456     * visible on Error_Handler surfaces such as admin notices and the dashboard),
457     * and a connected result clears both. Runs from every entry point of the test:
458     * Site Health page loads, Core's weekly Site Health cron, and the daily
459     * connection check on the heartbeat cron.
460     *
461     * @param string      $name        The test name.
462     * @param object|null $result      The JSON-decoded response body; null when the body was not valid JSON.
463     * @param int|string  $status_code The HTTP status code of the WP.com response.
464     *
465     * @return array Test results.
466     */
467    public function evaluate_wpcom_connection_result( $name, $result, $status_code ) {
468        if ( ! empty( $result->connected ) ) {
469            $this->clear_blocked_request_error();
470            $this->clear_ssl_verification_error();
471            return self::passing_test( array( 'name' => $name ) );
472        }
473
474        // The site itself rejected WordPress.com's request (firewall, WAF, security
475        // plugin, or server rule). The connection token could be valid, but reconnecting would
476        // be rejected the same way - surface the real cause and don't offer a reconnect.
477        if ( isset( $result->error_code ) && 'xmlrpc_request_blocked' === $result->error_code ) {
478            $site_http_status = (int) ( $result->site_http_status ?? 0 );
479
480            $this->report_connection_state_error(
481                'xmlrpc_request_blocked',
482                'WordPress.com requests to the site are blocked',
483                array( 'site_http_status' => $site_http_status )
484            );
485
486            // A 4xx/5xx from the site means WP.com completed the TLS handshake to get
487            // it, so a lingering SSL-verification error is provably stale.
488            $this->clear_ssl_verification_error();
489
490            return $this->blocked_request_failing_test( $name, $site_http_status );
491        }
492
493        // WP.com could not verify the site's SSL certificate when connecting to it
494        // (expired, self-signed, or incomplete chain). The site itself never sees these
495        // failures â€” the TLS handshake dies before PHP runs â€” so WP.com's response to
496        // this signed request is the only evidence, and reconnecting would be rejected
497        // the same way. A stored blocked error is preserved: a failed handshake proves
498        // nothing about a blockage, and ERROR_LIFE_TIME bounds any staleness.
499        if ( isset( $result->error_code ) && 'wpcom_ssl_verification_failed' === $result->error_code ) {
500            $this->report_connection_state_error( 'wpcom_ssl_verification_failed', 'WordPress.com cannot verify the SSL certificate of the site' );
501
502            return $this->ssl_verification_failing_test( $name );
503        }
504
505        // An explicit `connected` property (falsy here, past the pass branch) proves
506        // WP.com actually ran its test and did not report a blockage or a certificate
507        // failure â€” a definitive other failure, so a lingering blocked or SSL error is
508        // stale and its suppressed-reconnect presentation would be wrong for this
509        // failure. The exception is a result WP.com marked `inconclusive` (a transport
510        // failure it could not classify, e.g. a timeout): that neither confirms nor
511        // disproves a stored error, so preserve it â€” clearing would flap the notice
512        // for a broken site that is also occasionally slow. Malformed bodies and
513        // service-error envelopes (no `connected` property) are likewise preserved.
514        // A wrongly preserved error is bounded by ERROR_LIFE_TIME anyway.
515        if ( is_object( $result ) && property_exists( $result, 'connected' ) && empty( $result->inconclusive ) ) {
516            $this->clear_blocked_request_error();
517            $this->clear_ssl_verification_error();
518        }
519
520        // WP.com could not complete the test (a transport failure it could not classify â€” e.g.
521        // a timeout, or a dev/sandbox site it cannot reach back). That neither confirms nor
522        // disproves the connection, so don't present it as a definitive failure with a reconnect
523        // CTA â€” skip, as we already do for a cURL timeout on the outgoing request.
524        if ( is_object( $result ) && ! empty( $result->inconclusive ) ) {
525            return self::skipped_test(
526                array(
527                    'name'              => $name,
528                    'short_description' => __( 'WordPress.com could not complete the connection test. This is usually temporary.', 'jetpack-connection' ),
529                )
530            );
531        }
532
533        $message = isset( $result->message ) && '' !== $result->message
534            ? $result->message
535            : __( 'Connection test failed.', 'jetpack-connection' );
536
537        // Append the status code only when it adds signal: a 200 means the request itself
538        // succeeded (the failure is in the connection, not the transport), so "(status code: 200)"
539        // is confusing noise.
540        if ( 200 !== (int) $status_code ) {
541            $message .= ' ' . sprintf(
542                /* translators: %s is the HTTP status code returned by WordPress.com. */
543                __( '(status code: %s)', 'jetpack-connection' ),
544                $status_code
545            );
546        }
547
548        return self::connection_failing_test( $name, $message );
549    }
550
551    /**
552     * Reports a verified `local_state` connection error derived from a WP.com
553     * test-connection result.
554     *
555     * Skipping the WP.com verification round-trip is safe here: the error was
556     * derived from a response WP.com sent to a request this site initiated and
557     * signed, so it is self-evidencing (same trust model as the outgoing flow).
558     * The method_exists guard and the 'local_state' literal (which matches
559     * Error_Handler::ERROR_TYPE_LOCAL_STATE) protect mid-plugin-update requests,
560     * where a stale Error_Handler predating the factory and the constant can
561     * already be loaded: reporting is best-effort and must never fatal.
562     *
563     * @since 9.3.0
564     *
565     * @param string $error_code    The error code, one of Error_Handler::$known_errors.
566     * @param string $error_message The stored error message (display copy is resolved by the Error_Handler).
567     * @param array  $extra_data    Additional error data, merged over the defaults.
568     */
569    private function report_connection_state_error( $error_code, $error_message, array $extra_data = array() ) {
570        if ( ! method_exists( Error_Handler::class, 'build_connection_wp_error' ) ) {
571            return;
572        }
573
574        Error_Handler::get_instance()->report_error(
575            Error_Handler::build_connection_wp_error(
576                $error_code,
577                $error_message,
578                array( 'token' => '' ),
579                'local_state', // Error_Handler::ERROR_TYPE_LOCAL_STATE.
580                '', // Connection-state errors describe the site's environment, not one request, so they have no direction.
581                array_merge(
582                    array(
583                        'user_id' => 0,
584                        // Reconnecting cannot fix a connection-state error, so it carries
585                        // its remedy: no reconnect CTA on any surface.
586                        'action'  => 'none',
587                    ),
588                    $extra_data
589                )
590            ),
591            false,
592            true
593        );
594    }
595
596    /**
597     * Clears a stored `xmlrpc_request_blocked` error, when the loaded Error_Handler supports it.
598     *
599     * During a plugin update, a stale Error_Handler predating `delete_error_by_code()` can
600     * already be in memory while this file is the new version on disk. State sync is
601     * best-effort and must never fatal such a request, so it is skipped in that window.
602     *
603     * @since 8.10.0
604     */
605    private function clear_blocked_request_error() {
606        if ( method_exists( Error_Handler::class, 'delete_error_by_code' ) ) {
607            Error_Handler::get_instance()->delete_error_by_code( 'xmlrpc_request_blocked' );
608        }
609    }
610
611    /**
612     * Clears a stored `wpcom_ssl_verification_failed` error, when the loaded Error_Handler supports it.
613     *
614     * As with clear_blocked_request_error(), state sync is best-effort and skipped when a
615     * stale Error_Handler predating the method is loaded mid-plugin-update.
616     *
617     * @since 9.3.0
618     */
619    private function clear_ssl_verification_error() {
620        if ( method_exists( Error_Handler::class, 'delete_error_by_code' ) ) {
621            Error_Handler::get_instance()->delete_error_by_code( 'wpcom_ssl_verification_failed' );
622        }
623    }
624
625    /**
626     * Builds a failing result for the case where the site is blocking WordPress.com's
627     * connection test (e.g. firewall/WAF/security plugin).
628     *
629     * No reconnect action is offered because the connection token could be valid but
630     * reconnecting would be rejected the same way.
631     *
632     * @param string $name             The test name.
633     * @param int    $site_http_status The HTTP status the site returned, or 0 if unknown.
634     *
635     * @return array Test results.
636     */
637    protected function blocked_request_failing_test( $name, $site_http_status = 0 ) {
638        // Only the first sentence varies with the status code. Keeping the explanation
639        // in its own string means it is written, translated, and edited once.
640        $blocked = $site_http_status
641            ? sprintf(
642                /* translators: %d is the HTTP status code (e.g. 403) the site returned. */
643                __( 'WordPress.com reached your site but the request was blocked (HTTP %d).', 'jetpack-connection' ),
644                $site_http_status
645            )
646            : __( 'WordPress.com reached your site but the request was blocked.', 'jetpack-connection' );
647
648        $connection_error = $blocked . ' ' . __( 'This is usually caused by a security plugin, firewall, or server rule rejecting requests from WordPress.com.', 'jetpack-connection' );
649
650        $recommendation = sprintf(
651            /* translators: %1$s opens a link to Jetpack's IP allowlist documentation, %2$s closes it (it also carries hidden text noting the link opens in a new tab). Place them around the phrase that should be linked. */
652            __( 'Jetpack Connection uses your site\'s xmlrpc.php file to securely communicate with WordPress.com. Ask your host or security provider to %1$sallowlist Jetpack Connection IPs%2$s â€” reconnecting will not resolve this. If you need further help, contact Jetpack support.', 'jetpack-connection' ),
653            '<a href="' . esc_url( Redirect::get_url( 'https://jetpack.com/support/how-to-add-jetpack-ips-allowlist/' ) ) . '" target="_blank" rel="noopener noreferrer">',
654            sprintf(
655                /* translators: accessibility text */
656                '<span class="screen-reader-text"> %s</span></a>',
657                esc_html__( '(opens in a new tab)', 'jetpack-connection' )
658            )
659        );
660
661        return $this->connection_state_failing_test( $name, $connection_error, $recommendation );
662    }
663
664    /**
665     * Builds a failing result for the case where WordPress.com could not verify the
666     * site's SSL certificate when connecting to it.
667     *
668     * No reconnect action is offered because a reconnect would fail certificate
669     * verification the same way.
670     *
671     * @since 9.3.0
672     *
673     * @param string $name The test name.
674     *
675     * @return array Test results.
676     */
677    protected function ssl_verification_failing_test( $name ) {
678        $connection_error = __( 'WordPress.com could not establish a secure connection to your site because your site\'s SSL certificate could not be verified. This is usually caused by an expired or self-signed certificate, or a missing intermediate certificate.', 'jetpack-connection' );
679
680        $recommendation = __( 'Ask your hosting provider to renew your site\'s SSL certificate or complete its certificate chain. Reconnecting will not resolve this. If you need further help, contact Jetpack support.', 'jetpack-connection' );
681
682        return $this->connection_state_failing_test( $name, $connection_error, $recommendation );
683    }
684
685    /**
686     * Builds a failing result for a connection-state failure that reconnecting cannot fix.
687     *
688     * No reconnect action is offered; contacting support is the only CTA.
689     *
690     * @since 9.3.0
691     *
692     * @param string $name             The test name.
693     * @param string $connection_error The connection-specific error copy.
694     * @param string $recommendation   The recommendation for resolving it.
695     *
696     * @return array Test results.
697     */
698    protected function connection_state_failing_test( $name, $connection_error, $recommendation ) {
699        return self::failing_test(
700            array(
701                'name'              => $name,
702                'label'             => __( 'Your site is blocking requests from WordPress.com', 'jetpack-connection' ),
703                'short_description' => $connection_error,
704                'long_description'  => self::helper_get_reconnect_long_description( $connection_error, $recommendation ),
705                'action_label'      => $this->helper_get_support_text(),
706                'action'            => $this->helper_get_support_url(),
707            )
708        );
709    }
710
711    /**
712     * Tests the port number to ensure it is an expected value.
713     *
714     * @return array
715     */
716    protected function test__server_port_value() {
717        $name = 'test__server_port_value';
718
719        if ( ! isset( $_SERVER['HTTP_X_FORWARDED_PORT'] ) && ! isset( $_SERVER['SERVER_PORT'] ) ) {
720            return self::skipped_test(
721                array(
722                    'name'              => $name,
723                    'short_description' => __( 'The server port values are not defined. This is most common when running PHP via a CLI.', 'jetpack-connection' ),
724                )
725            );
726        }
727        $site_port   = wp_parse_url( home_url(), PHP_URL_PORT );
728        $server_port = isset( $_SERVER['HTTP_X_FORWARDED_PORT'] ) ? (int) $_SERVER['HTTP_X_FORWARDED_PORT'] : (int) $_SERVER['SERVER_PORT'];
729        $http_ports  = array( 80 );
730        $https_ports = array( 80, 443 );
731
732        if ( defined( 'JETPACK_SIGNATURE__HTTP_PORT' ) ) {
733            $http_ports[] = JETPACK_SIGNATURE__HTTP_PORT;
734        }
735
736        if ( defined( 'JETPACK_SIGNATURE__HTTPS_PORT' ) ) {
737            $https_ports[] = JETPACK_SIGNATURE__HTTPS_PORT;
738        }
739
740        if ( $site_port ) {
741            return self::skipped_test( array( 'name' => $name ) );
742        }
743
744        if ( is_ssl() && in_array( $server_port, $https_ports, true ) ) {
745            return self::passing_test( array( 'name' => $name ) );
746        } elseif ( in_array( $server_port, $http_ports, true ) ) {
747            return self::passing_test( array( 'name' => $name ) );
748        }
749
750        if ( is_ssl() ) {
751            $needed_constant = 'JETPACK_SIGNATURE__HTTPS_PORT';
752        } else {
753            $needed_constant = 'JETPACK_SIGNATURE__HTTP_PORT';
754        }
755        return self::failing_test(
756            array(
757                'name'              => $name,
758                'short_description' => sprintf(
759                    /* translators: %1$s - a PHP code snippet */
760                    __(
761                        'The server port value is unexpected.
762                    Try adding the following to your wp-config.php file: %1$s',
763                        'jetpack-connection'
764                    ),
765                    "define( '$needed_constant', $server_port )"
766                ),
767            )
768        );
769    }
770
771    /**
772     * Test that PHP's XML library is installed.
773     *
774     * @return array Test results.
775     */
776    protected function test__xml_parser_available() {
777        $name = 'test__xml_parser_available';
778        if ( function_exists( 'xml_parser_create' ) ) {
779            return self::passing_test( array( 'name' => $name ) );
780        }
781
782        return self::failing_test(
783            array(
784                'name'              => $name,
785                'label'             => __( 'PHP XML manipulation libraries are not available.', 'jetpack-connection' ),
786                'short_description' => __( 'Please ask your hosting provider to refer to our server requirements and enable PHP\'s XML module.', 'jetpack-connection' ),
787                'action_label'      => __( 'View our server requirements', 'jetpack-connection' ),
788                'action'            => Redirect::get_url( 'jetpack-support-server-requirements' ),
789            )
790        );
791    }
792
793    /**
794     * Calls to WP.com to run the connection diagnostic testing suite.
795     *
796     * Intentionally added last as it will be skipped if any local failed conditions exist.
797     *
798     * @since 7.1.0
799     *
800     * @return array Test results.
801     */
802    protected function last__wpcom_self_test() {
803        $name = 'test__wpcom_self_test';
804
805        $status = new Status();
806        if ( ! ( new Manager() )->is_connected() || $status->is_offline_mode() || $status->in_safe_mode() || ! $this->pass ) {
807            return self::skipped_test( array( 'name' => $name ) );
808        }
809
810        $self_xml_rpc_url = site_url( 'xmlrpc.php' );
811
812        $api_base = Constants::get_constant( 'JETPACK__API_BASE' );
813        if ( ! $api_base ) {
814            $api_base = Utils::DEFAULT_JETPACK__API_BASE;
815        }
816        $testsite_url = $api_base . 'testsite/1/?url=';
817
818        add_filter( 'http_request_timeout', array( static::class, 'increase_timeout' ), PHP_INT_MAX - 1 );
819
820        $response = wp_remote_get( $testsite_url . $self_xml_rpc_url );
821
822        remove_filter( 'http_request_timeout', array( static::class, 'increase_timeout' ), PHP_INT_MAX - 1 );
823
824        if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
825            return self::passing_test( array( 'name' => $name ) );
826        } elseif ( is_wp_error( $response ) && str_contains( $response->get_error_message(), 'cURL error 28' ) ) {
827            return self::skipped_test(
828                array(
829                    'name'              => $name,
830                    'short_description' => self::helper_get_timeout_text(),
831                )
832            );
833        }
834
835        return self::failing_test(
836            array(
837                'name'              => $name,
838                'short_description' => sprintf(
839                    /* translators: %1$s - A debugging url */
840                    __( 'Jetpack.com detected an error on the WP.com Self Test. Visit the Jetpack Debug page for more info: %1$s, or contact support.', 'jetpack-connection' ),
841                    Redirect::get_url( 'jetpack-support-debug', array( 'query' => 'url=' . rawurlencode( site_url() ) ) )
842                ),
843                'action_label'      => $this->helper_get_support_text(),
844                'action'            => $this->helper_get_support_url(),
845            )
846        );
847    }
848}