diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f945136b..eb2eae09 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,6 +69,7 @@ jobs: 'EndToEnd/subscribe/order-pending-payment', 'EndToEnd/subscribe/order-processing', 'EndToEnd/sync-past-orders', + 'EndToEnd/uninstall', 'Integration' ] diff --git a/composer.json b/composer.json index 53ec04c2..eeb22608 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "type": "project", "license": "GPLv3", "require": { - "convertkit/convertkit-wordpress-libraries": "2.1.3" + "convertkit/convertkit-wordpress-libraries": "2.1.5" }, "require-dev": { "php-webdriver/webdriver": "^1.0", diff --git a/includes/class-ckwc-integration.php b/includes/class-ckwc-integration.php index 2d184679..ed2920c2 100644 --- a/includes/class-ckwc-integration.php +++ b/includes/class-ckwc-integration.php @@ -144,6 +144,8 @@ public function delete_credentials() { $this->update_option( 'access_token', '' ); $this->update_option( 'refresh_token', '' ); $this->update_option( 'token_expires', '' ); + $this->update_option( 'api_key', '' ); + $this->update_option( 'api_secret', '' ); // Clear any existing scheduled WordPress Cron event. wp_clear_scheduled_hook( 'ckwc_refresh_token' ); @@ -171,11 +173,46 @@ public function maybe_disconnect() { return; } - // Delete resources. - $this->resources_delete(); + // Setup API. + $api = new CKWC_API( + CKWC_OAUTH_CLIENT_ID, + CKWC_OAUTH_CLIENT_REDIRECT_URI, + $this->get_access_token(), + $this->get_refresh_token(), + $this->get_option_bool( 'debug' ) + ); + + // Check that we're using the Kit WordPress Libraries 2.1.4 or higher. + // If another Kit Plugin is active and out of date, its libraries might + // be loaded that don't have this method. + if ( ! method_exists( $api, 'revoke_tokens' ) ) { // @phpstan-ignore-line Older WordPress Libraries won't have this function. + wp_safe_redirect( + ckwc_get_settings_link( + array( + 'error' => __( 'The Kit WordPress Libraries is missing the `revoke_tokens` method. Please update all Kit WordPress Plugins to their latest versions, and click Disconnect again.', 'woocommerce-convertkit' ), + ) + ) + ); + exit(); + } - // Remove tokens from settings. - $this->delete_credentials(); + // Revoke Access and Refresh Tokens. + // See ckwc_delete_credentials() method in functions.php, which is called + // by the `convertkit_api_revoke_tokens` action and deletes credentials from the Plugin's settings. + $result = $api->revoke_tokens(); + if ( is_wp_error( $result ) ) { + wp_safe_redirect( + ckwc_get_settings_link( + array( + 'error' => $result->get_error_message(), + ) + ) + ); + exit(); + } + + // Delete cached resources. + $this->resources_delete(); // Redirect to General screen, which will now show the Plugin's settings, because the Plugin // is now authenticated. diff --git a/includes/functions.php b/includes/functions.php index 3682f73c..a977e222 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -242,10 +242,40 @@ function ckwc_maybe_delete_credentials( $result, $client_id ) { } +/** + * Deletes the stored access token, refresh token and its expiry from the Plugin settings, + * and clears any existing scheduled WordPress Cron event to refresh the token on expiry, + * when the user revokes the access token. + * + * @since 2.1.3 + * + * @param string $client_id OAuth Client ID used for the Access and Refresh Tokens. + */ +function ckwc_delete_credentials( $client_id ) { + + // Don't delete these credentials if they're not for this Client ID. + // They're for another Kit Plugin that uses OAuth. + if ( $client_id !== CKWC_OAUTH_CLIENT_ID ) { + return; + } + + // Bail if the integration is unavailable. + if ( ! function_exists( 'WP_CKWC_Integration' ) ) { + return; + } + + // Delete Access and Refresh Tokens. + WP_CKWC_Integration()->delete_credentials(); + +} + // Update Access Token when refreshed by the API class. add_action( 'convertkit_api_get_access_token', 'ckwc_maybe_update_credentials', 10, 2 ); add_action( 'convertkit_api_refresh_token', 'ckwc_maybe_update_credentials', 10, 2 ); +// Delete credentials when the user revokes the access and refresh tokens. +add_action( 'convertkit_api_revoke_tokens', 'ckwc_delete_credentials', 10, 1 ); + // Delete credentials if the API class uses a invalid access token. // This prevents the Plugin making repetitive API requests that will 401. add_action( 'convertkit_api_access_token_invalid', 'ckwc_maybe_delete_credentials', 10, 2 ); diff --git a/tests/EndToEnd/settings/SettingOAuthCest.php b/tests/EndToEnd/settings/SettingOAuthCest.php index ce443168..f785e70a 100644 --- a/tests/EndToEnd/settings/SettingOAuthCest.php +++ b/tests/EndToEnd/settings/SettingOAuthCest.php @@ -78,8 +78,8 @@ public function testInvalidCredentials(EndToEndTester $I) // Setup Plugin. $I->setupConvertKitPlugin( $I, - 'fakeAccessToken', - 'fakeRefreshToken' + accessToken: 'fakeAccessToken', + refreshToken: 'fakeRefreshToken' ); // Load Settings screen. @@ -138,15 +138,57 @@ public function testValidCredentials(EndToEndTester $I) // Wait for confirmation message to display. $I->waitForElementVisible('div.updated.inline'); + } + + /** + * Test that the credentials and resources are deleted on disconnect. + * + * @since 2.1.3 + * + * @param EndToEndTester $I Tester. + */ + public function testCredentialsAndResourcesAreDeletedOnDisconnect(EndToEndTester $I) + { + // Setup Plugin. + $I->setupConvertKitPlugin($I); - // Disconnect the Plugin connection to ConvertKit. + // Load Settings screen. + $I->loadConvertKitSettingsScreen($I); + + // Fake the API Key, Access and Refresh Tokens; if we revoke the tokens used for tests, future tests will fail. + $I->setupConvertKitPlugin( + $I, + accessToken: 'fakeAccessToken', + refreshToken: 'fakeRefreshToken', + apiKey: 'fakeAPIKey', + apiSecret: 'fakeAPISecret' + ); + + // Disconnect the Plugin connection to Kit. $I->click('Disconnect'); + // Check credentials are removed from the settings. + $settings = $I->grabOptionFromDatabase('woocommerce_ckwc_settings'); + $I->assertEmpty($settings['access_token']); + $I->assertEmpty($settings['refresh_token']); + $I->assertEmpty($settings['token_expires']); + $I->assertEmpty($settings['api_key']); + $I->assertEmpty($settings['api_secret']); + + // Check cached resources are removed from the database on disconnection. + $I->dontSeeOptionInDatabase('ckwc_custom_fields'); + $I->dontSeeOptionInDatabase('ckwc_custom_fields_last_queried'); + $I->dontSeeOptionInDatabase('ckwc_forms'); + $I->dontSeeOptionInDatabase('ckwc_forms_last_queried'); + $I->dontSeeOptionInDatabase('ckwc_sequences'); + $I->dontSeeOptionInDatabase('ckwc_sequences_last_queried'); + $I->dontSeeOptionInDatabase('ckwc_tags'); + $I->dontSeeOptionInDatabase('ckwc_tags_last_queried'); + // Confirm the Connect button displays. - $I->waitForElementVisible('#oauth a.button-primary'); $I->see('Connect'); $I->dontSee('Disconnect'); - $I->dontSeeElementInDOM('button.woocommerce-save-button'); + $I->dontSeeElementInDOM('input#submit'); } /** diff --git a/tests/EndToEnd/uninstall/UninstallCest.php b/tests/EndToEnd/uninstall/UninstallCest.php new file mode 100644 index 00000000..8b379cec --- /dev/null +++ b/tests/EndToEnd/uninstall/UninstallCest.php @@ -0,0 +1,102 @@ +activateConvertKitPlugin($I); + + // Generate an access token and refresh token by API key and secret. + // We don't use the tokens from the environment, as revoking those + // would result in later tests failing. + $result = wp_remote_post( + 'https://api.kit.com/wordpress/accounts/oauth_access_token', + [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode( + [ + 'api_key' => $_ENV['CONVERTKIT_API_KEY'], + 'api_secret' => $_ENV['CONVERTKIT_API_SECRET'], + 'client_id' => $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + 'tenant_name' => wp_generate_password( 10, false ), // Random tenant name to produce a token for this request only. + ] + ), + ] + ); + $tokens = json_decode(wp_remote_retrieve_body($result), true)['oauth']; + + // Store the tokens and API keys in the Plugin's settings. + $I->setupConvertKitPlugin( + $I, + accessToken: $tokens['access_token'], + refreshToken: $tokens['refresh_token'], + apiKey: $_ENV['CONVERTKIT_API_KEY'], + apiSecret: $_ENV['CONVERTKIT_API_SECRET'] + ); + + // Deactivate the Plugin. + $I->deactivateConvertKitPlugin($I); + + // Delete the Plugin. + $I->deleteKitPlugin($I); + + // Confirm the credentials have been removed from the Plugin's settings. + $I->wait(3); + $settings = $I->grabOptionFromDatabase('woocommerce_ckwc_settings'); + $I->assertEmpty($settings['access_token']); + $I->assertEmpty($settings['refresh_token']); + $I->assertEmpty($settings['api_key']); + $I->assertEmpty($settings['api_secret']); + + // Confirm attempting to use the revoked access token no longer works. + $result = wp_remote_get( + 'https://api.kit.com/v4/account', + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $tokens['access_token'], + ], + ] + ); + $data = json_decode(wp_remote_retrieve_body($result), true); + $I->assertArrayHasKey( 'errors', $data ); + $I->assertEquals( 'The access token was revoked', $data['errors'][0] ); + + // Confirm attempting to use the revoked refresh token no longer works. + $result = wp_remote_post( + 'https://api.kit.com/v4/oauth/token', + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $tokens['access_token'], + ], + 'body' => [ + 'client_id' => $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + 'grant_type' => 'refresh_token', + 'refresh_token' => $tokens['refresh_token'], + ], + ] + ); + $data = json_decode(wp_remote_retrieve_body($result), true); + $I->assertArrayHasKey( 'error', $data ); + $I->assertEquals( 'invalid_grant', $data['error'] ); + } +} diff --git a/tests/Integration/APITest.php b/tests/Integration/APITest.php index 08b7e25d..7478e62f 100644 --- a/tests/Integration/APITest.php +++ b/tests/Integration/APITest.php @@ -166,6 +166,67 @@ public function testCronEventCreatedWhenTokenRefreshed() $this->assertGreaterThanOrEqual( $nextScheduledTimestamp, time() + 10000 ); } + /** + * Test that the access token and refresh token are deleted from the Plugin's settings + * when the access token is revoked. + * + * @since 2.1.3 + */ + public function testCredentialsDeletedAndInvalidWhenRevoked() + { + // Initialize the API without an access token or refresh token. + $api = new \CKWC_API( + $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + $_ENV['KIT_OAUTH_REDIRECT_URI'] + ); + + // Generate an access token by API key and secret. + $result = $api->get_access_token_by_api_key_and_secret( + $_ENV['CONVERTKIT_API_KEY'], + $_ENV['CONVERTKIT_API_SECRET'], + wp_generate_password( 10, false ) // Random tenant name to produce a token for this request only. + ); + + // Store the access token in the Plugin's settings. + WP_CKWC_Integration()->update_option( 'access_token', $result['oauth']['access_token'] ); + WP_CKWC_Integration()->update_option( 'refresh_token', $result['oauth']['refresh_token'] ); + WP_CKWC_Integration()->update_option( 'token_expires', $result['oauth']['expires_at'] ); + + // Initialize the API with the access token and refresh token. + $api = new \CKWC_API( + $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + $_ENV['KIT_OAUTH_REDIRECT_URI'], + WP_CKWC_Integration()->get_access_token(), + WP_CKWC_Integration()->get_refresh_token() + ); + + // Confirm the token works when making an authenticated request. + $this->assertNotInstanceOf( 'WP_Error', $api->get_account() ); + + // Revoke the access and refresh tokens. + $api->revoke_tokens(); + + // Confirm the access token and refresh token are deleted from the Plugin's settings. + $this->assertEmpty( WP_CKWC_Integration()->get_access_token() ); + $this->assertEmpty( WP_CKWC_Integration()->get_refresh_token() ); + + // Initialize the API with the (now revoked) access token and refresh token. + // revoke_tokens() will have removed the access token and refresh token from the API class, so we need to provide them again + // to test they're revoked. + $api = new \CKWC_API( + $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + $_ENV['CONVERTKIT_OAUTH_REDIRECT_URI'], + $result['oauth']['access_token'], + $result['oauth']['refresh_token'] + ); + + // Confirm attempting to use the revoked access token no longer works. + $this->assertInstanceOf( 'WP_Error', $api->get_account() ); + + // Confirm attempting to use the revoked refresh token no longer works. + $this->assertInstanceOf( 'WP_Error', $api->refresh_token() ); + } + /** * Test that the User Agent string is in the expected format and * includes the Plugin's name and version number. diff --git a/tests/Support/Helper/Plugin.php b/tests/Support/Helper/Plugin.php index 11a9d96c..46494631 100644 --- a/tests/Support/Helper/Plugin.php +++ b/tests/Support/Helper/Plugin.php @@ -35,6 +35,19 @@ public function deactivateConvertKitPlugin($I) $I->deactivateThirdPartyPlugin($I, 'convertkit-for-woocommerce'); } + /** + * Helper method to delete the Kit Plugin, checking + * it deleted and no errors were output. + * + * @since 2.1.3 + * + * @param EndToEndTester $I EndToEndTester. + */ + public function deleteKitPlugin($I) + { + $I->deleteThirdPartyPlugin($I, 'convertkit-for-woocommerce'); + } + /** * Helper method to activate the following Plugins: * - WooCommerce @@ -93,6 +106,8 @@ public function deactivateWooCommerceAndConvertKitPlugins($I) * @param EndToEndTester $I Acceptance Tester. * @param bool|string $accessToken Access Token (if specified, used instead of CONVERTKIT_OAUTH_ACCESS_TOKEN). * @param bool|string $refreshToken Refresh Token (if specified, used instead of CONVERTKIT_OAUTH_REFRESH_TOKEN). + * @param bool|string $apiKey v3 API Key. + * @param bool|string $apiSecret v3 API Secret. * @param string $subscriptionEvent Subscribe Event. * @param bool|string $subscription Form, Tag or Sequence to subscribe customer to. * @param string $nameFormat Name Format. @@ -108,6 +123,8 @@ public function setupConvertKitPlugin( $I, $accessToken = false, $refreshToken = false, + $apiKey = false, + $apiSecret = false, $subscriptionEvent = 'pending', $subscription = false, $nameFormat = 'first', diff --git a/tests/Support/Helper/ThirdPartyPlugin.php b/tests/Support/Helper/ThirdPartyPlugin.php index 96c56088..db0332b5 100644 --- a/tests/Support/Helper/ThirdPartyPlugin.php +++ b/tests/Support/Helper/ThirdPartyPlugin.php @@ -95,6 +95,39 @@ public function deactivateThirdPartyPlugin($I, $name) } } + /** + * Helper method to delete a third party Plugin, checking + * it deleted and no errors were output. + * + * @since 2.1.3 + * + * @param EndToEndTester $I EndToEnd Tester. + * @param string $name Plugin Slug. + */ + public function deleteThirdPartyPlugin($I, $name) + { + // Login as the Administrator, if we're not already logged in. + if ( ! $this->amLoggedInAsAdmin($I) ) { + $this->doLoginAsAdmin($I); + } + + // Go to the Plugins screen in the WordPress Administration interface. + $I->amOnPluginsPage(); + + // Wait for the Plugins page to load. + $I->waitForElementVisible('body.plugins-php'); + + // Delete the Plugin. + $I->waitForElementVisible('a#delete-' . $name); + $I->click('a#delete-' . $name); + + // Click the confirmation dialog. + $I->acceptPopup(); + + // Wait for the Plugin to be marked as deleted. + $I->waitForElementNotVisible('table.plugins tr.deleted[data-slug=' . $name . ']'); + } + /** * Helper method to check if the Administrator is logged in. * diff --git a/tests/Support/Helper/WooCommerce.php b/tests/Support/Helper/WooCommerce.php index 0da84e24..88f5c834 100644 --- a/tests/Support/Helper/WooCommerce.php +++ b/tests/Support/Helper/WooCommerce.php @@ -266,15 +266,15 @@ public function wooCommerceCreateProductAndCheckoutWithConfig($I, $options = fal // Setup ConvertKit for WooCommerce Plugin. $I->setupConvertKitPlugin( $I, - $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], - $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], - $options['subscription_event'], - $options['plugin_form_tag_sequence'], - $options['name_format'], - $options['custom_fields'], - $options['display_opt_in'], - ( ( $options['send_purchase_data'] === true ) ? 'processing' : $options['send_purchase_data'] ), - $options['address_fields'] + accessToken: $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + refreshToken: $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], + subscriptionEvent: $options['subscription_event'], + subscription: $options['plugin_form_tag_sequence'], + nameFormat: $options['name_format'], + mapCustomFields: $options['custom_fields'], + displayOptIn: $options['display_opt_in'], + sendPurchaseDataEvent: ( ( $options['send_purchase_data'] === true ) ? 'processing' : $options['send_purchase_data'] ), + addressFields: $options['address_fields'] ); // Create Product. diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 00000000..2f79ee66 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,75 @@ + Delete. + * + * @package CKWC + * @author ConvertKit + */ + +// If uninstall.php is not called by WordPress, die. +if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { + die; +} + +// Only WordPress and PHP methods can be used. Plugin classes and methods +// are not reliably available due to the Plugin being deactivated and going +// through deletion now. + +// Get settings. +$settings = get_option( 'woocommerce_ckwc_settings' ); + +// Bail if no settings exist. +if ( ! $settings ) { + return; +} + +// Revoke Access Token. +if ( array_key_exists( 'access_token', $settings ) && ! empty( $settings['access_token'] ) ) { + wp_remote_post( + 'https://api.kit.com/v4/oauth/revoke', + array( + 'headers' => array( + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( + array( + 'client_id' => 'L0kyADsB3WP5zO5MvUpXQU64gIntQg9BBAIme17r_7A', + 'token' => $settings['access_token'], + ) + ), + 'timeout' => 5, + ) + ); +} + +// Revoke Refresh Token. +if ( array_key_exists( 'refresh_token', $settings ) && ! empty( $settings['refresh_token'] ) ) { + wp_remote_post( + 'https://api.kit.com/v4/oauth/revoke', + array( + 'headers' => array( + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( + array( + 'client_id' => 'L0kyADsB3WP5zO5MvUpXQU64gIntQg9BBAIme17r_7A', + 'token' => $settings['refresh_token'], + ) + ), + 'timeout' => 5, + ) + ); +} + +// Remove credentials from settings. +$settings['access_token'] = ''; +$settings['refresh_token'] = ''; +$settings['token_expires'] = ''; +$settings['api_key'] = ''; +$settings['api_secret'] = ''; + +// Save settings. +update_option( 'woocommerce_ckwc_settings', $settings );