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