From a50d82efe7c4c33ea386bb3d045e8740bf954d34 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 26 Jun 2025 17:50:56 +0800 Subject: [PATCH 1/6] OAuth: Automatically Refresh Token on Expiry --- includes/class-convertkit-mm-settings.php | 11 ++++- includes/cron-functions.php | 58 +++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 includes/cron-functions.php diff --git a/includes/class-convertkit-mm-settings.php b/includes/class-convertkit-mm-settings.php index ab1b250..d1cc58b 100644 --- a/includes/class-convertkit-mm-settings.php +++ b/includes/class-convertkit-mm-settings.php @@ -50,6 +50,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 ); } @@ -281,8 +282,8 @@ public function get_bundle_cancellation_mapping( $id ) { } /** - * 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 1.3.0 * @@ -305,6 +306,12 @@ public function update_credentials( $result, $client_id ) { ) ); + // Clear any existing scheduled WordPress Cron event. + wp_clear_scheduled_hook( 'convertkit_mm_refresh_token' ); + + // Schedule a WordPress Cron event to refresh the token on expiry. + wp_schedule_single_event( ( $result['created_at'] + $result['expires_in'] ), 'convertkit_mm_refresh_token' ); + } /** diff --git a/includes/cron-functions.php b/includes/cron-functions.php new file mode 100644 index 0000000..4c9b39d --- /dev/null +++ b/includes/cron-functions.php @@ -0,0 +1,58 @@ +has_access_token() ) { + return; + } + if ( ! $settings->has_refresh_token() ) { + return; + } + + // Initialize the API. + $api = new ConvertKit_MM_API( + 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_mm_refresh_token', 'convertkit_mm_refresh_token' ); \ No newline at end of file From 8af61a0d4c50bf526801a4c8c5f412d3703780d4 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 26 Jun 2025 17:51:01 +0800 Subject: [PATCH 2/6] Added Tests --- tests/Integration.suite.yml | 2 +- tests/Integration/APITest.php | 61 ++++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/tests/Integration.suite.yml b/tests/Integration.suite.yml index ca29b4c..9229d2b 100644 --- a/tests/Integration.suite.yml +++ b/tests/Integration.suite.yml @@ -17,5 +17,5 @@ modules: domain: '%WORDPRESS_DOMAIN%' adminEmail: 'admin@%WORDPRESS_DOMAIN%' title: 'Integration Tests' - plugins: ['./integrate-convertkit-wpforms.php'] + plugins: ['./convertkit-membermouse.php'] theme: '' \ No newline at end of file diff --git a/tests/Integration/APITest.php b/tests/Integration/APITest.php index 17bcd22..2f12859 100644 --- a/tests/Integration/APITest.php +++ b/tests/Integration/APITest.php @@ -28,6 +28,16 @@ class APITest extends WPTestCase */ private $api; + /** + * Holds the current timestamp, defined in setUp to fix + * it for all tests. + * + * @since 1.3.3 + * + * @var int + */ + private $now = 0; + /** * Performs actions before each test. * @@ -37,6 +47,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-membermouse/convertkit-membermouse.php'); @@ -78,7 +91,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(); @@ -89,6 +102,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 1.3.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_mm_refresh_token' ); + $this->assertEquals( $nextScheduledTimestamp, $this->now + 10000 ); + } + + /** + * Test that a WordPress Cron event is created when an access token is refreshed. + * + * @since 1.3.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_mm_refresh_token' ); + $this->assertEquals( $nextScheduledTimestamp, $this->now + 10000 ); + } + /** * Mocks an API response as if the Access Token expired. * @@ -139,7 +192,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 ) { @@ -147,7 +200,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, @@ -159,7 +212,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', ) From 5d108489c4e70d8de03cc031547d22ed764d4e88 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 26 Jun 2025 17:51:07 +0800 Subject: [PATCH 3/6] Run Integration Tests on GitHub Action --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 57abdc1..34470b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,7 +55,8 @@ jobs: 'EndToEnd/general', 'EndToEnd/member', 'EndToEnd/member-update', - 'EndToEnd/product' + 'EndToEnd/product', + 'Integration' ] # Steps to install, configure and run tests From 374cc0e67f5a8ed4628cf18e1549717a7a9bae29 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 26 Jun 2025 17:55:19 +0800 Subject: [PATCH 4/6] Include `cron-functions.php` --- convertkit-membermouse.php | 1 + 1 file changed, 1 insertion(+) diff --git a/convertkit-membermouse.php b/convertkit-membermouse.php index c80bc27..07d5138 100644 --- a/convertkit-membermouse.php +++ b/convertkit-membermouse.php @@ -61,6 +61,7 @@ require CONVERTKIT_MM_PATH . 'includes/class-convertkit-mm-settings.php'; require CONVERTKIT_MM_PATH . 'includes/class-convertkit-mm.php'; require CONVERTKIT_MM_PATH . 'includes/convertkit-mm-functions.php'; +require CONVERTKIT_MM_PATH . 'includes/cron-functions.php'; require CONVERTKIT_MM_PATH . 'admin/class-convertkit-mm-admin.php'; /** From 56b60c11905e1aba896c16189666a7c2e2747f8b Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 26 Jun 2025 18:01:13 +0800 Subject: [PATCH 5/6] Fix coding standards --- includes/cron-functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/cron-functions.php b/includes/cron-functions.php index 4c9b39d..6071f55 100644 --- a/includes/cron-functions.php +++ b/includes/cron-functions.php @@ -55,4 +55,4 @@ function convertkit_mm_refresh_token() { // 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_mm_refresh_token', 'convertkit_mm_refresh_token' ); \ No newline at end of file +add_action( 'convertkit_mm_refresh_token', 'convertkit_mm_refresh_token' ); From 57ca7afbc7fa09dbb0fab001252dea800a086acb Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 27 Jun 2025 09:13:11 +0800 Subject: [PATCH 6/6] =?UTF-8?q?Use=20time(),=20not=20`created=5Fat`=20to?= =?UTF-8?q?=20calculate=20token=E2=80=99s=20expiry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/class-convertkit-mm-admin.php | 2 +- includes/class-convertkit-mm-settings.php | 4 ++-- includes/cron-functions.php | 2 +- tests/Integration/APITest.php | 21 ++++----------------- 4 files changed, 8 insertions(+), 21 deletions(-) diff --git a/admin/class-convertkit-mm-admin.php b/admin/class-convertkit-mm-admin.php index a08caaf..6d81ac4 100644 --- a/admin/class-convertkit-mm-admin.php +++ b/admin/class-convertkit-mm-admin.php @@ -137,7 +137,7 @@ private function maybe_get_and_store_access_token() { array( 'access_token' => $result['access_token'], 'refresh_token' => $result['refresh_token'], - 'token_expires' => ( $result['created_at'] + $result['expires_in'] ), + 'token_expires' => ( time() + $result['expires_in'] ), ) ); diff --git a/includes/class-convertkit-mm-settings.php b/includes/class-convertkit-mm-settings.php index d1cc58b..3d5ee07 100644 --- a/includes/class-convertkit-mm-settings.php +++ b/includes/class-convertkit-mm-settings.php @@ -302,7 +302,7 @@ public function update_credentials( $result, $client_id ) { array( 'access_token' => $result['access_token'], 'refresh_token' => $result['refresh_token'], - 'token_expires' => ( $result['created_at'] + $result['expires_in'] ), + 'token_expires' => ( time() + $result['expires_in'] ), ) ); @@ -310,7 +310,7 @@ public function update_credentials( $result, $client_id ) { wp_clear_scheduled_hook( 'convertkit_mm_refresh_token' ); // Schedule a WordPress Cron event to refresh the token on expiry. - wp_schedule_single_event( ( $result['created_at'] + $result['expires_in'] ), 'convertkit_mm_refresh_token' ); + wp_schedule_single_event( ( time() + $result['expires_in'] ), 'convertkit_mm_refresh_token' ); } diff --git a/includes/cron-functions.php b/includes/cron-functions.php index 6071f55..6a8d12d 100644 --- a/includes/cron-functions.php +++ b/includes/cron-functions.php @@ -47,7 +47,7 @@ function convertkit_mm_refresh_token() { array( 'access_token' => $result['access_token'], 'refresh_token' => $result['refresh_token'], - 'token_expires' => ( $result['created_at'] + $result['expires_in'] ), + 'token_expires' => ( time() + $result['expires_in'] ), ) ); diff --git a/tests/Integration/APITest.php b/tests/Integration/APITest.php index 2f12859..71d345f 100644 --- a/tests/Integration/APITest.php +++ b/tests/Integration/APITest.php @@ -28,16 +28,6 @@ class APITest extends WPTestCase */ private $api; - /** - * Holds the current timestamp, defined in setUp to fix - * it for all tests. - * - * @since 1.3.3 - * - * @var int - */ - private $now = 0; - /** * Performs actions before each test. * @@ -47,9 +37,6 @@ 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-membermouse/convertkit-membermouse.php'); @@ -119,7 +106,7 @@ public function testCronEventCreatedWhenAccessTokenObtained() // 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_mm_refresh_token' ); - $this->assertEquals( $nextScheduledTimestamp, $this->now + 10000 ); + $this->assertGreaterThanOrEqual( $nextScheduledTimestamp, time() + 10000 ); } /** @@ -139,7 +126,7 @@ public function testCronEventCreatedWhenTokenRefreshed() // 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_mm_refresh_token' ); - $this->assertEquals( $nextScheduledTimestamp, $this->now + 10000 ); + $this->assertGreaterThanOrEqual( $nextScheduledTimestamp, time() + 10000 ); } /** @@ -212,8 +199,8 @@ public function mockTokenResponse( $response, $parsed_args, $url ) 'access_token' => $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], 'refresh_token' => $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], 'token_type' => 'bearer', - 'created_at' => $this->now, - 'expires_in' => 10000, + 'created_at' => 1735660800, // When the access token was created. + 'expires_in' => 10000, // When the access token will expire, relative to the time the request was made. 'scope' => 'public', ) ),