diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1bc543f..f5ca0aa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,6 +59,7 @@ jobs: 'EndToEnd/forms', 'EndToEnd/general', 'EndToEnd/recommendations', + 'EndToEnd/uninstall', 'Integration' ] diff --git a/composer.json b/composer.json index 99b621c..7a4850e 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-integrate-convertkit-wpforms.php b/includes/class-integrate-convertkit-wpforms.php index b87e7e1..023c4cf 100644 --- a/includes/class-integrate-convertkit-wpforms.php +++ b/includes/class-integrate-convertkit-wpforms.php @@ -962,6 +962,31 @@ public function delete_resource_cache() { // Get API instance. $api = $this->get_api_instance( $account_id ); + // 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_send_json_error( + 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.', 'integrate-convertkit-wpforms' ), + ) + ); + } + + // Revoke Access and Refresh Tokens. + // See integrate_convertkit_wpforms_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(); + + // Bail if an error occurred. + if ( is_wp_error( $result ) ) { + wp_send_json_error( + array( + 'error' => $result->get_error_message(), + ) + ); + } + // Delete cached resources. $resource_forms = new Integrate_ConvertKit_WPForms_Resource_Forms( $api, $account_id ); $resource_sequences = new Integrate_ConvertKit_WPForms_Resource_Sequences( $api, $account_id ); diff --git a/tests/EndToEnd/forms/FormCest.php b/tests/EndToEnd/forms/FormCest.php index ba53690..dee7cf9 100644 --- a/tests/EndToEnd/forms/FormCest.php +++ b/tests/EndToEnd/forms/FormCest.php @@ -812,11 +812,9 @@ private function _wpFormsCompleteAndSubmitForm(EndToEndTester $I, int $pageID, s // Check that a review request was created. $I->reviewRequestExists($I); - // Disconnect the account. - $I->disconnectAccount($I, $this->accountID); - - // Check that the resources are no longer cached under the given account ID. - $I->dontSeeCachedResourcesInDatabase($I, $this->accountID); + // Remove the provider connection. + // We don't disconnect the account, as this would now revoke the tokens and cause later tests to fail. + $I->removeProviderConnection($I, $this->accountID); } /** diff --git a/tests/EndToEnd/general/IntegrationsCest.php b/tests/EndToEnd/general/IntegrationsCest.php index 08858f6..5d38354 100644 --- a/tests/EndToEnd/general/IntegrationsCest.php +++ b/tests/EndToEnd/general/IntegrationsCest.php @@ -91,17 +91,6 @@ public function testAddIntegrationWithValidCredentials(EndToEndTester $I) ), $reconnectURL ); - - // Confirm that the connection can be disconnected. - $I->click('Disconnect'); - - // Confirm that we want to disconnect. - $I->waitForElementVisible('.jconfirm-box'); - $I->click('.jconfirm-box button.btn-confirm'); - - // Confirm no connection is listed. - $I->wait(3); - $I->dontSee('Connected on:'); } /** @@ -138,8 +127,8 @@ public function testInvalidCredentials(EndToEndTester $I) // Define connection with invalid API credentials. $I->setupWPFormsIntegration( $I, - 'fakeAccessToken', - 'fakeRefreshToken' + accessToken: 'fakeAccessToken', + refreshToken: 'fakeRefreshToken' ); // Setup WPForms Form and configuration for this test. @@ -178,6 +167,57 @@ public function testInvalidCredentials(EndToEndTester $I) $I->seeErrorNotice($I, 'Kit for WPForms: Authorization failed. Please reconnect your Kit account.'); } + /** + * Test that the credentials and resources are deleted on disconnect. + * + * @since 1.9.2 + * + * @param EndToEndTester $I Tester. + */ + public function testCredentialsAndResourcesAreDeletedOnDisconnect(EndToEndTester $I) + { + // Define a random account ID. + $accountID = 'kit-' . wp_generate_password( 10, false ); + + // Fake the API Key, Access and Refresh Tokens; if we revoke the tokens used for tests, future tests will fail. + $I->setupWPFormsIntegration( + $I, + accessToken: 'fakeAccessToken', + refreshToken: 'fakeRefreshToken', + apiKey: 'fakeAPIKey', + apiSecret: 'fakeAPISecret', + accountID: $accountID + ); + + // Load WPForms > Settings > Integrations. + $I->amOnAdminPage('admin.php?page=wpforms-settings&view=integrations'); + + // Expand Kit integration section. + $I->click('#wpforms-integration-convertkit'); + + // Disconnect the connection to Kit. + $I->waitForElementVisible('a[data-provider="convertkit"]'); + $I->click('Disconnect'); + + // Confirm that we want to disconnect. + $I->waitForElementVisible('.jconfirm-box'); + $I->click('.jconfirm-box button.btn-confirm'); + + // Confirm no connection is listed. + $I->wait(3); + $I->dontSee('Connected on:'); + + // Check connection is removed from the settings. + // Clicking 'Disconnect' in WPForms removes the connection from the settings, + // including any credentials within that connection. + $providers = $I->grabOptionFromDatabase('wpforms_providers'); + $I->assertArrayHasKey('convertkit', $providers); + $I->assertCount(0, $providers['convertkit']); + + // Check cached resources are removed from the database on disconnection. + $I->dontSeeCachedResourcesInDatabase($I, $accountID); + } + /** * Deactivate and reset Plugin(s) after each test, if the test passes. * We don't use _after, as this would provide a screenshot of the Plugin diff --git a/tests/EndToEnd/uninstall/UninstallCest.php b/tests/EndToEnd/uninstall/UninstallCest.php new file mode 100644 index 0000000..83aa463 --- /dev/null +++ b/tests/EndToEnd/uninstall/UninstallCest.php @@ -0,0 +1,103 @@ +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->setupWPFormsIntegration( + $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('wpforms_providers'); + $connection = reset($settings['convertkit']); + $I->assertEmpty($connection['access_token']); + $I->assertEmpty($connection['refresh_token']); + $I->assertEmpty($connection['api_key']); + $I->assertEmpty($connection['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 81ab917..40acb43 100644 --- a/tests/Integration/APITest.php +++ b/tests/Integration/APITest.php @@ -200,6 +200,58 @@ 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 1.9.2 + */ + public function testCredentialsDeletedAndInvalidWhenRevoked() + { + // Initialize the API without an access token or refresh token. + $api = new \Integrate_ConvertKit_WPForms_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. + ); + + // Initialize the API with the access token and refresh token. + $api = new \Integrate_ConvertKit_WPForms_API( + $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + $_ENV['KIT_OAUTH_REDIRECT_URI'], + $result['oauth']['access_token'], + $result['oauth']['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(); + + // 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 \Integrate_ConvertKit_WPForms_API( + $_ENV['CONVERTKIT_OAUTH_CLIENT_ID'], + $_ENV['KIT_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() ); + } + /** * Mocks an API response as if the Access Token expired. * diff --git a/tests/Support/Helper/Plugin.php b/tests/Support/Helper/Plugin.php index 2aece54..4492308 100644 --- a/tests/Support/Helper/Plugin.php +++ b/tests/Support/Helper/Plugin.php @@ -35,6 +35,18 @@ public function deactivateConvertKitPlugin($I) $I->deactivateThirdPartyPlugin($I, 'integrate-convertkit-wpforms'); } + /** + * Helper method to delete the Kit Plugin. + * + * @since 1.9.2 + * + * @param EndToEndTester $I EndToEndTester. + */ + public function deleteKitPlugin($I) + { + $I->deleteThirdPartyPlugin($I, 'integrate-convertkit-wpforms'); + } + /** * Helper method to determine that the order of the Form resources in the given * select element are in the expected alphabetical order. diff --git a/tests/Support/Helper/ThirdPartyPlugin.php b/tests/Support/Helper/ThirdPartyPlugin.php index edf0ac5..9ca7f73 100644 --- a/tests/Support/Helper/ThirdPartyPlugin.php +++ b/tests/Support/Helper/ThirdPartyPlugin.php @@ -74,6 +74,39 @@ public function deactivateThirdPartyPlugin($I, $name) $I->waitForElementVisible('table.plugins tr[data-slug=' . $name . '].inactive'); } + /** + * Helper method to delete a third party Plugin, checking + * it deleted and no errors were output. + * + * @since 1.9.2 + * + * @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/WPForms.php b/tests/Support/Helper/WPForms.php index 1e34ce2..3608579 100644 --- a/tests/Support/Helper/WPForms.php +++ b/tests/Support/Helper/WPForms.php @@ -17,10 +17,12 @@ class WPForms extends \Codeception\Module * @param EndToEndTester $I Tester. * @param bool|string $accessToken Access Token (if not specified, CONVERTKIT_OAUTH_ACCESS_TOKEN is used). * @param bool|string $refreshToken Refresh Token (if not specified, CONVERTKIT_OAUTH_REFRESH_TOKEN is used). + * @param bool|string $apiKey API Key (if not specified, CONVERTKIT_API_KEY is used). + * @param bool|string $apiSecret API Secret (if not specified, CONVERTKIT_API_SECRET is used). * @param string $accountID Kit Account ID. * @return string Account ID in WPForms. */ - public function setupWPFormsIntegration($I, $accessToken = false, $refreshToken = false, $accountID = false) + public function setupWPFormsIntegration($I, $accessToken = false, $refreshToken = false, $apiKey = false, $apiSecret = false, $accountID = false) { $accountID = 'kit-' . ( $accountID ? $accountID : $_ENV['CONVERTKIT_API_ACCOUNT_ID'] ); @@ -31,6 +33,8 @@ public function setupWPFormsIntegration($I, $accessToken = false, $refreshToken $accountID => [ 'access_token' => $accessToken ? $accessToken : $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], 'refresh_token' => $refreshToken ? $refreshToken : $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], + 'api_key' => $apiKey ? $apiKey : $_ENV['CONVERTKIT_API_KEY'], + 'api_secret' => $apiSecret ? $apiSecret : $_ENV['CONVERTKIT_API_SECRET'], 'label' => 'Kit', 'date' => strtotime('now'), ], @@ -561,32 +565,17 @@ public function createPageWithWPFormsShortcode($I, $formID) } /** - * Disconnects the given account ID via the UI in WPForms > Settings > Integrations. + * Removes the given account ID from the WPForms provider settings. * * @since 1.7.0 * * @param EndToEndTester $I Tester. * @param string $accountID Account ID. */ - public function disconnectAccount($I, $accountID) + public function removeProviderConnection($I, $accountID) { - // Login as the Administrator, if we're not already logged in. - if ( ! $I->amLoggedInAsAdmin($I) ) { - $I->doLoginAsAdmin($I); - } - - // Click Disconnect. - $I->amOnAdminPage('admin.php?page=wpforms-settings&view=integrations'); - $I->click('#wpforms-integration-convertkit'); - $I->waitForElementVisible('#wpforms-integration-convertkit .wpforms-settings-provider-accounts-list span.remove a[data-key="' . $accountID . '"]'); - $I->click('#wpforms-integration-convertkit .wpforms-settings-provider-accounts-list span.remove a[data-key="' . $accountID . '"]'); - - // Confirm that we want to disconnect. - $I->waitForElementVisible('.jconfirm-box'); - $I->click('.jconfirm-box button.btn-confirm'); - - // Confirm connection is no longer listed. - $I->wait(5); - $I->dontSeeElementInDOM('a[data-key="' . $accountID . '"]'); + $providers = $I->grabOptionFromDatabase('wpforms_providers'); + unset($providers['convertkit'][ $accountID ]); + $I->haveOptionInDatabase('wpforms_providers', $providers); } } diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..84f2cce --- /dev/null +++ b/uninstall.php @@ -0,0 +1,83 @@ + 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 providers. +$providers = get_option( 'wpforms_providers' ); + +// Bail if no providers exist. +if ( ! $providers ) { + return; +} + +// Bail if no Kit connections exist. +if ( ! array_key_exists( 'convertkit', $providers ) ) { + return; +} + +// Iterate through each connection, revoking the tokens. +foreach ( $providers['convertkit'] as $account_id => $connection ) { + // Revoke Access Token. + if ( array_key_exists( 'access_token', $connection ) && ! empty( $connection['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' => $connection['access_token'], + ) + ), + 'timeout' => 5, + ) + ); + } + + // Revoke Refresh Token. + if ( array_key_exists( 'refresh_token', $connection ) && ! empty( $connection['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' => $connection['refresh_token'], + ) + ), + 'timeout' => 5, + ) + ); + } + + // Remove credentials from settings. + $providers['convertkit'][ $account_id ]['access_token'] = ''; + $providers['convertkit'][ $account_id ]['refresh_token'] = ''; + $providers['convertkit'][ $account_id ]['token_expires'] = ''; + $providers['convertkit'][ $account_id ]['api_key'] = ''; + $providers['convertkit'][ $account_id ]['api_secret'] = ''; +} + +// Save settings. +update_option( 'wpforms_providers', $providers );