diff --git a/.env.dist.testing b/.env.dist.testing index cc9386b..18ab5f7 100644 --- a/.env.dist.testing +++ b/.env.dist.testing @@ -8,6 +8,7 @@ TEST_SITE_ADMIN_USERNAME=admin TEST_SITE_ADMIN_PASSWORD=password TEST_SITE_WP_ADMIN_PATH=/wp-admin WP_ROOT_FOLDER="/home/runner/work/convertkit-wordpress-libraries/convertkit-wordpress-libraries/wordpress" +WP_ENVIRONMENT_TYPE=local TEST_DB_NAME=test TEST_DB_HOST=localhost TEST_DB_USER=root @@ -16,6 +17,7 @@ TEST_TABLE_PREFIX=wp_ TEST_SITE_WP_URL=http://127.0.0.1 TEST_SITE_WP_DOMAIN=127.0.0.1 TEST_SITE_ADMIN_EMAIL=wordpress@convertkit.local +TEST_SITE_CONFIG_FILE="/home/runner/work/convertkit-wordpress-libraries/convertkit-wordpress-libraries/wordpress/wp-content/plugins/convertkit-wordpress-libraries/tests/_support/WpunitTesterConfig.php" CONVERTKIT_API_BROADCAST_ID="8697158" CONVERTKIT_API_CUSTOM_FIELD_ID="258240" CONVERTKIT_API_FORM_ID="2765139" diff --git a/src/class-convertkit-api-v4.php b/src/class-convertkit-api-v4.php index 69ec9d1..ba13158 100644 --- a/src/class-convertkit-api-v4.php +++ b/src/class-convertkit-api-v4.php @@ -1468,6 +1468,14 @@ public function request( $endpoint, $method = 'get', $params = array(), $retry_i break; } + // Don't automatically refresh the expired access token if we're not on a production environment. + // This prevents the same ConvertKit account used on both a staging and production site from + // reaching a race condition where the staging site refreshes the token first, resulting in + // the production site unable to later refresh its same expired access token. + if ( ! $this->is_production_site() ) { + break; + } + // Refresh the access token. $result = $this->refresh_token(); @@ -1506,6 +1514,27 @@ public function request( $endpoint, $method = 'get', $params = array(), $retry_i } + /** + * Helper method to determine the WordPress environment type, checking + * if the wp_get_environment_type() function exists in WordPress (versions + * older than WordPress 5.5 won't have this function). + * + * @since 2.0.2 + * + * @return bool + */ + private function is_production_site() { + + // If the WordPress wp_get_environment_type() function isn't available, + // assume this is a production site. + if ( ! function_exists( 'wp_get_environment_type' ) ) { + return true; + } + + return ( wp_get_environment_type() === 'production' ); + + } + /** * Inspects the given API response for errors, returning them as a string. * diff --git a/tests/_support/WpunitTesterConfig.php b/tests/_support/WpunitTesterConfig.php new file mode 100644 index 0000000..c53b3ff --- /dev/null +++ b/tests/_support/WpunitTesterConfig.php @@ -0,0 +1,11 @@ +assertEquals($result->get_error_code(), 'convertkit_api_error'); } + /** + * Test that making a call with an expired access token results in refresh_token() + * not being automatically called, when the WordPress site isn't a production site. + * + * @since 2.0.2 + * + * @return void + */ + public function testRefreshTokenWhenAccessTokenExpiredErrorOnNonProductionSite() + { + // If the refresh token action in the libraries is triggered when calling get_account(), the test failed. + add_action( + 'convertkit_api_refresh_token', + function() { + $this->fail('`convertkit_api_refresh_token` was triggered when calling `get_account` with an expired access token on a non-production site.'); + } + ); + + // Filter requests to mock the token expiry and refreshing the token. + add_filter( 'pre_http_request', array( $this, 'mockAccessTokenExpiredResponse' ), 10, 3 ); + add_filter( 'pre_http_request', array( $this, 'mockRefreshTokenResponse' ), 10, 3 ); + + // Run request, which will trigger the above filters as if the token expired and refreshes automatically. + $result = $this->api->get_account(); + } + /** * Test that supplying no API credentials to the API class returns a WP_Error. * @@ -6230,6 +6256,90 @@ function( $response ) use ( $httpCode, $httpMessage, $body ) { // phpcs:ignore G ); } + /** + * Mocks an API response as if the Access Token expired. + * + * @since 2.0.2 + * + * @param mixed $response HTTP Response. + * @param array $parsed_args Request arguments. + * @param string $url Request URL. + * @return mixed + */ + public function mockAccessTokenExpiredResponse( $response, $parsed_args, $url ) + { + // Only mock requests made to the /account endpoint. + if ( strpos( $url, 'https://api.convertkit.com/v4/account' ) === false ) { + return $response; + } + + // Remove this filter, so we don't end up in a loop when retrying the request. + remove_filter( 'pre_http_request', array( $this, 'mockAccessTokenExpiredResponse' ) ); + + // Return a 401 unauthorized response with the errors body as if the API + // returned "The access token expired". + return array( + 'headers' => array(), + 'body' => wp_json_encode( + array( + 'errors' => array( + 'The access token expired', + ), + ) + ), + 'response' => array( + 'code' => 401, + 'message' => 'The access token expired', + ), + 'cookies' => array(), + 'http_response' => null, + ); + } + + /** + * Mocks an API response as if a refresh token was used to fetch new tokens. + * + * @since 2.0.2 + * + * @param mixed $response HTTP Response. + * @param array $parsed_args Request arguments. + * @param string $url Request URL. + * @return mixed + */ + public function mockRefreshTokenResponse( $response, $parsed_args, $url ) + { + // Only mock requests made to the /token endpoint. + if ( strpos( $url, 'https://api.convertkit.com/oauth/token' ) === false ) { + return $response; + } + + // Remove this filter, so we don't end up in a loop when retrying the request. + remove_filter( 'pre_http_request', array( $this, 'mockRefreshTokenResponse' ) ); + + // Return a mock access and refresh token for this API request, as calling + // refresh_token results in a new access and refresh token being provided, + // which would result in other tests breaking due to changed tokens. + return array( + 'headers' => array(), + 'body' => wp_json_encode( + array( + 'access_token' => 'new-' . $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + 'refresh_token' => 'new-' . $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], + 'token_type' => 'bearer', + 'created_at' => strtotime( 'now' ), + 'expires_in' => 10000, + 'scope' => 'public', + ) + ), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'cookies' => array(), + 'http_response' => null, + ); + } + /** * Helper method to assert the given key exists as an array * in the API response.