diff --git a/includes/class-convertkit-settings.php b/includes/class-convertkit-settings.php index f55437396..bad085f26 100644 --- a/includes/class-convertkit-settings.php +++ b/includes/class-convertkit-settings.php @@ -46,6 +46,7 @@ public function __construct() { } // Update Access Token when refreshed by the API class. + add_action( 'convertkit_api_get_access_token', array( $this, 'update_credentials' ), 10, 2 ); add_action( 'convertkit_api_refresh_token', array( $this, 'update_credentials' ), 10, 2 ); } @@ -500,10 +501,10 @@ public function get_defaults() { } /** - * Saves the new access token, refresh token and its expiry when the API - * class automatically refreshes an outdated access token. + * Saves the new access token, refresh token and its expiry, and schedules + * a WordPress Cron event to refresh the token on expiry. * - * @since 2.5.0 + * @since 2.8.3 * * @param array $result New Access Token, Refresh Token and Expiry. * @param string $client_id OAuth Client ID used for the Access and Refresh Tokens. @@ -511,7 +512,7 @@ public function get_defaults() { public function update_credentials( $result, $client_id ) { // Don't save these credentials if they're not for this Client ID. - // They're for another ConvertKit Plugin that uses OAuth. + // They're for another Kit Plugin that uses OAuth. if ( $client_id !== CONVERTKIT_OAUTH_CLIENT_ID ) { return; } @@ -524,6 +525,12 @@ public function update_credentials( $result, $client_id ) { ) ); + // Clear any existing scheduled WordPress Cron event. + wp_clear_scheduled_hook( 'convertkit_refresh_token' ); + + // Schedule a WordPress Cron event to refresh the token on expiry. + wp_schedule_single_event( ( $result['created_at'] + $result['expires_in'] ), 'convertkit_refresh_token' ); + } /** diff --git a/includes/cron-functions.php b/includes/cron-functions.php index 6adc05421..0bbc7e77d 100644 --- a/includes/cron-functions.php +++ b/includes/cron-functions.php @@ -6,6 +6,57 @@ * @author ConvertKit */ +/** + * Refresh the OAuth access token, triggered by WordPress' Cron. + * + * @since 2.8.3 + */ +function convertkit_refresh_token() { + + // Get Settings and Log classes. + $settings = new ConvertKit_Settings(); + + // Bail if no existing access and refresh token exists. + if ( ! $settings->has_access_token() ) { + return; + } + if ( ! $settings->has_refresh_token() ) { + return; + } + + // Initialize the API. + $api = new ConvertKit_API_V4( + CONVERTKIT_OAUTH_CLIENT_ID, + CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI, + $settings->get_access_token(), + $settings->get_refresh_token(), + $settings->debug_enabled(), + 'cron_refresh_token' + ); + + // Refresh the token. + $result = $api->refresh_token(); + + // If an error occured, don't save the new tokens. + // Logging is handled by the ConvertKit_API_V4 class. + if ( is_wp_error( $result ) ) { + return; + } + + $settings->save( + array( + 'access_token' => $result['access_token'], + 'refresh_token' => $result['refresh_token'], + 'token_expires' => ( $result['created_at'] + $result['expires_in'] ), + ) + ); + +} + +// Register action to run above function; this action is created by WordPress' wp_schedule_event() function +// in update_credentials() in the ConvertKit_Settings class. +add_action( 'convertkit_refresh_token', 'convertkit_refresh_token' ); + /** * Refresh the Posts Resource cache, triggered by WordPress' Cron. * diff --git a/tests/Integration/APITest.php b/tests/Integration/APITest.php index 7816af641..9f7e44d55 100644 --- a/tests/Integration/APITest.php +++ b/tests/Integration/APITest.php @@ -27,6 +27,16 @@ class APITest extends WPTestCase */ private $api; + /** + * Holds the current timestamp, defined in setUp to fix + * it for all tests. + * + * @since 2.8.3 + * + * @var int + */ + private $now = 0; + /** * Performs actions before each test. * @@ -36,6 +46,9 @@ public function setUp(): void { parent::setUp(); + // Set the current timestamp to the start of the test. + $this->now = strtotime( 'now' ); + // Activate Plugin, to include the Plugin's constants in tests. activate_plugins('convertkit/wp-convertkit.php'); @@ -77,7 +90,7 @@ public function testAccessTokenRefreshedAndSavedWhenExpired() // 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 ); + add_filter( 'pre_http_request', array( $this, 'mockTokenResponse' ), 10, 3 ); // Run request, which will trigger the above filters as if the token expired and refreshes automatically. $result = $this->api->get_account(); @@ -88,6 +101,46 @@ public function testAccessTokenRefreshedAndSavedWhenExpired() $this->assertEquals( $settings->get_refresh_token(), $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'] ); } + /** + * Test that a WordPress Cron event is created when an access token is obtained. + * + * @since 2.8.3 + */ + public function testCronEventCreatedWhenAccessTokenObtained() + { + // Mock request as if the API returned an access and refresh token when a request + // was made to refresh the token. + add_filter( 'pre_http_request', array( $this, 'mockTokenResponse' ), 10, 3 ); + + // Run request, as if the access token was obtained successfully. + $result = $this->api->get_access_token( 'mockAuthCode' ); + + // Confirm the Cron event to refresh the access token was created, and the timestamp to + // run the refresh token call matches the expiry of the access token. + $nextScheduledTimestamp = wp_next_scheduled( 'convertkit_refresh_token' ); + $this->assertEquals( $nextScheduledTimestamp, $this->now + 10000 ); + } + + /** + * Test that a WordPress Cron event is created when an access token is refreshed. + * + * @since 2.8.3 + */ + public function testCronEventCreatedWhenTokenRefreshed() + { + // Mock request as if the API returned an access and refresh token when a request + // was made to refresh the token. + add_filter( 'pre_http_request', array( $this, 'mockTokenResponse' ), 10, 3 ); + + // Run request, as if the token was refreshed. + $result = $this->api->refresh_token(); + + // Confirm the Cron event to refresh the access token was created, and the timestamp to + // run the refresh token call matches the expiry of the access token. + $nextScheduledTimestamp = wp_next_scheduled( 'convertkit_refresh_token' ); + $this->assertEquals( $nextScheduledTimestamp, $this->now + 10000 ); + } + /** * Mocks an API response as if the Access Token expired. * @@ -138,7 +191,7 @@ public function mockAccessTokenExpiredResponse( $response, $parsed_args, $url ) * @param string $url Request URL. * @return mixed */ - public function mockRefreshTokenResponse( $response, $parsed_args, $url ) + public function mockTokenResponse( $response, $parsed_args, $url ) { // Only mock requests made to the /token endpoint. if ( strpos( $url, 'https://api.kit.com/oauth/token' ) === false ) { @@ -146,7 +199,7 @@ public function mockRefreshTokenResponse( $response, $parsed_args, $url ) } // Remove this filter, so we don't end up in a loop when retrying the request. - remove_filter( 'pre_http_request', array( $this, 'mockRefreshTokenResponse' ) ); + remove_filter( 'pre_http_request', array( $this, 'mockTokenResponse' ) ); // 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, @@ -158,7 +211,7 @@ public function mockRefreshTokenResponse( $response, $parsed_args, $url ) 'access_token' => $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], 'refresh_token' => $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], 'token_type' => 'bearer', - 'created_at' => strtotime( 'now' ), + 'created_at' => $this->now, 'expires_in' => 10000, 'scope' => 'public', )