diff --git a/README.md b/README.md index 8404f73..04579a7 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,6 @@ add_action('init', function(){ 'menu_slug' => $client->slug . '-manage-license', 'icon_url' => '', 'position' => null, - 'parent_slug' => '', 'activated_redirect' => admin_url( 'admin.php?page=my-plugin-page' ), // should you want to redirect on activation of license. 'deactivated_redirect' => admin_url( 'admin.php?page=my-plugin-deactivation-page' ), // should you want to redirect on detactivation of license. ] @@ -109,8 +108,6 @@ add_action('init', function(){ }); ``` -Make sure you call this function directly, never use any action hook to call this function. - > For plugins example code that needs to be used on your main plugin file. > For themes example code that needs to be used on your themes `functions.php` file. @@ -148,9 +145,9 @@ $client->set_textdomain( 'your-project-textdomain' ); ``` ## Example Plugin -[surecart-plugin-license.zip](https://d.pr/f/6ZyfAS) +[surecart-plugin-license.zip](https://d.pr/f/6MPcya) ## Example Theme -[surecart-theme-license.zip](https://d.pr/f/RK8NFS) +[surecart-theme-license.zip](https://d.pr/f/mYc39R) For full setup and functionality details, see the official documentation: [Licensing Setup and Functionality](https://surecart.com/docs/licensing-setup-and-functionality/). diff --git a/src/Client.php b/src/Client.php index fd2ad0b..935508a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -12,7 +12,7 @@ class Client { * * @var string */ - public $version = '1.2.1'; + public $version = '1.2.2'; /** * Name of the plugin diff --git a/src/License.php b/src/License.php index 6a69214..b6c6144 100644 --- a/src/License.php +++ b/src/License.php @@ -57,6 +57,9 @@ public function retrieve( $license_key ) { * @throws \Exception If something goes wrong. */ public function activate( $key = '' ) { + $settings = $this->client->settings(); + $previous = $settings->get_options(); // restored if this attempt fails. + $activation_id = ''; try { // validate the license and store it. $license = $this->validate( $key, true ); @@ -65,21 +68,30 @@ public function activate( $key = '' ) { if ( is_wp_error( $activation ) ) { throw new \Exception( $activation->get_error_message() ); } - $this->client->settings()->activation_id = $activation->id; + $activation_id = $activation->id; + $settings->activation_id = $activation_id; // validate the release. $this->validate_release(); } catch ( \Exception $e ) { - // undo activation. - $activation = $this->client->activation()->get(); - if ( $activation ) { - $this->client->activation()->delete(); + // undo the activation this attempt created. + $undone = $activation_id ? $this->client->activation()->delete( $activation_id ) : true; + if ( is_wp_error( $undone ) && 'not_found' !== $undone->get_error_code() ) { + // keep only its id, so Deactivate License can remove it. + $settings->clear_options(); + $settings->activation_id = $activation_id; + $this->client->updater()->clear_cache(); + return new \WP_Error( 'error', $e->getMessage() . ' ' . $this->client->__( 'The activation could not be undone. Click Deactivate License to remove it.' ) ); } - // on error, clear options. - $this->client->settings()->clear_options(); + + // restore the previous options, so a failed or interrupted request never loses a saved key. + $settings->set_options( $previous ); + // return \WP_Error. return new \WP_Error( 'error', $e->getMessage() ); } + $this->client->updater()->clear_cache(); + return true; } @@ -101,12 +113,14 @@ public function deactivate( $activation_id = '' ) { // it has been deleted remotely. if ( 'not_found' === $deactivated->get_error_code() ) { $this->client->settings()->clear_options(); + $this->client->updater()->clear_cache(); return true; } return $deactivated; } $this->client->settings()->clear_options(); + $this->client->updater()->clear_cache(); return true; } @@ -148,6 +162,11 @@ public function get_current_release( $expires_in = 900 ) { * @throws \Exception If the license is not valid. */ public function validate( $key, $store = false ) { + // without a key, the request would hit the licenses collection. + if ( '' === trim( (string) $key ) ) { + throw new \Exception( $this->client->__( 'Please enter a license key.' ) ); + } + // get license. $license = $this->retrieve( sanitize_text_field( $key ) ); if ( is_wp_error( $license ) ) { diff --git a/src/Settings.php b/src/Settings.php index 2d6d04f..5924986 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -165,13 +165,24 @@ public function get_options() { return (array) get_option( $this->option_key, array() ); } + /** + * Replace all options. + * + * @param array $options The options. + * + * @return bool + */ + public function set_options( $options ) { + return update_option( $this->option_key, (array) $options ); + } + /** * Clear out the options. * * @return bool */ public function clear_options() { - return update_option( $this->option_key, array() ); + return $this->set_options( array() ); } /** @@ -222,27 +233,20 @@ public function settings_output() {
-

menu_args['page_title'] ); ?>

- - - - - - - - - + client->__( 'Activate License' ) : $this->client->__( 'Deactivate License' ) ); ?> @@ -345,7 +349,12 @@ public function license_form_submit() { // handle activation. if ( 'activate' === $_POST['_action'] ) { - $activated = $this->client->license()->activate( sanitize_text_field( $_POST['license_key'] ) ); + $license_key = isset( $_POST['license_key'] ) ? sanitize_text_field( wp_unslash( $_POST['license_key'] ) ) : ''; + // the form doesn't print the stored key, so an empty field reuses it. + if ( '' === $license_key ) { + $license_key = (string) $this->license_key; + } + $activated = $this->client->license()->activate( $license_key ); if ( is_wp_error( $activated ) ) { $this->add_error( $activated->get_error_code(), $activated->get_error_message() ); return; @@ -361,9 +370,11 @@ public function license_form_submit() { // handle deactivation. if ( 'deactivate' === $_POST['_action'] ) { - $deactivated = $this->client->license()->deactivate( sanitize_text_field( $_POST['activation_id'] ) ); + // deactivate the stored activation, never one the form posts. + $deactivated = $this->client->license()->deactivate(); if ( is_wp_error( $deactivated ) ) { $this->add_error( $deactivated->get_error_code(), $deactivated->get_error_message() ); + return; } if ( ! empty( $this->menu_args['deactivated_redirect'] ) ) { @@ -387,7 +398,7 @@ public function redirect( $url ) { ?>
cache_key, $value, 3 * HOUR_IN_SECONDS ); } + /** + * Clear the cached version info and WordPress' update data, which hold the package link. + * Call it when the license changes. + * + * @return void + */ + public function clear_cache() { + delete_transient( $this->cache_key ); + delete_site_transient( 'theme' === $this->client->type ? 'update_themes' : 'update_plugins' ); + } + /** * Get plugin info from SureCart\Licensing */ diff --git a/tests/unit/LicenseActivationTest.php b/tests/unit/LicenseActivationTest.php new file mode 100644 index 0000000..ce418cd --- /dev/null +++ b/tests/unit/LicenseActivationTest.php @@ -0,0 +1,191 @@ +client = new \SureCart\Licensing\Client( 'SureCart', __FILE__ ); + $this->requests = array(); + $this->responses = array(); + add_filter( 'pre_http_request', array( $this, 'stub' ), 10, 3 ); + } + + public function tear_down() { + remove_filter( 'pre_http_request', array( $this, 'stub' ), 10 ); + parent::tear_down(); + } + + /** + * Answer licensing API requests from $this->responses. + * + * @param false|array|\WP_Error $pre Unused, required by the filter. + * @param array $args Request arguments. + * @param string $url Request URL. + * + * @return array|\WP_Error + */ + public function stub( $pre, $args, $url ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + $path = preg_replace( '#^https?://[^/]+/#', '', strtok( $url, '?' ) ); + $request = $args['method'] . ' ' . $path; + $this->requests[] = $request; + + foreach ( $this->responses as $prefix => $response ) { + if ( 0 !== strpos( $request, $prefix ) ) { + continue; + } + if ( is_wp_error( $response ) ) { + return $response; + } + return array( + 'headers' => array(), + 'cookies' => array(), + 'response' => array( + 'code' => $response[0], + 'message' => '', + ), + 'body' => wp_json_encode( $response[1] ), + ); + } + + return new \WP_Error( 'unexpected_request', $request ); + } + + /** + * A valid license, a created activation, and a release for $slug. + * + * @param string $slug The release slug. + */ + private function stub_activation( $slug ) { + $this->responses = array( + // before 'GET .../KEY', which is its prefix. + 'GET v1/public/licenses/KEY/expose_current_release' => array( + 200, + array( + 'url' => 'https://example.com/package.zip', + 'release_json' => array( + 'slug' => $slug, + 'version' => '9.9.9', + ), + ), + ), + 'GET v1/public/licenses/KEY' => array( + 200, + array( + 'id' => 'lic_1', + 'key' => 'KEY', + 'status' => 'active', + ), + ), + 'POST v1/public/activations/' => array( 201, array( 'id' => 'act_new' ) ), + ); + } + + public function test_empty_key_fails_without_a_request() { + $result = $this->client->license()->activate( '' ); + + $this->assertWPError( $result ); + $this->assertSame( array(), $this->requests ); + } + + public function test_release_error_deletes_the_new_activation_by_id() { + $this->stub_activation( 'another-product' ); + $this->responses['DELETE v1/public/activations/act_new'] = array( 200, array( 'deleted' => true ) ); + + $result = $this->client->license()->activate( 'KEY' ); + + $this->assertWPError( $result ); + $this->assertContains( 'DELETE v1/public/activations/act_new', $this->requests ); + $this->assertSame( array(), $this->client->settings()->get_options() ); + } + + public function test_undo_404_counts_as_removed() { + $this->stub_activation( 'another-product' ); + $this->responses['DELETE v1/public/activations/act_new'] = array( 404, array() ); + + $result = $this->client->license()->activate( 'KEY' ); + + $this->assertWPError( $result ); + $this->assertSame( array(), $this->client->settings()->get_options() ); + } + + public function test_failed_undo_keeps_only_the_activation_id() { + $this->stub_activation( 'another-product' ); + $this->responses['DELETE v1/public/activations/act_new'] = array( 500, array() ); + + $result = $this->client->license()->activate( 'KEY' ); + + $this->assertWPError( $result ); + $this->assertStringContainsString( 'Deactivate License', $result->get_error_message() ); + $this->assertSame( array( 'sc_activation_id' => 'act_new' ), $this->client->settings()->get_options() ); + } + + public function test_failed_request_restores_the_saved_key() { + $previous = array( + 'sc_license_key' => 'KEY', + 'sc_license_id' => 'lic_1', + 'sc_activation_id' => 'act_removed', + ); + $this->client->settings()->set_options( $previous ); + $this->responses['GET v1/public/licenses/KEY'] = new \WP_Error( 'http_request_failed', 'Timed out' ); + + $result = $this->client->license()->activate( 'KEY' ); + + $this->assertWPError( $result ); + $this->assertSame( $previous, $this->client->settings()->get_options() ); + } + + public function test_activation_and_deactivation_clear_update_caches() { + $this->stub_activation( $this->client->slug ); + $this->responses['DELETE v1/public/activations/act_new'] = array( 200, array( 'deleted' => true ) ); + + $this->prime_update_caches(); + $this->assertTrue( $this->client->license()->activate( 'KEY' ) ); + $this->assert_update_caches_cleared(); + + $this->prime_update_caches(); + $this->assertTrue( $this->client->license()->deactivate() ); + $this->assert_update_caches_cleared(); + } + + /** + * The SDK's version info cache key. + * + * @return string + */ + private function cache_key() { + return 'surecart_' . md5( $this->client->slug ) . '_version_info'; + } + + private function prime_update_caches() { + set_transient( $this->cache_key(), (object) array( 'new_version' => '1.0.0' ) ); + set_site_transient( 'update_plugins', (object) array( 'response' => array() ) ); + } + + private function assert_update_caches_cleared() { + $this->assertFalse( get_transient( $this->cache_key() ) ); + $this->assertFalse( get_site_transient( 'update_plugins' ) ); + } +} diff --git a/tests/unit/SettingsFormTest.php b/tests/unit/SettingsFormTest.php new file mode 100644 index 0000000..5f1245f --- /dev/null +++ b/tests/unit/SettingsFormTest.php @@ -0,0 +1,95 @@ +client = new \SureCart\Licensing\Client( 'SureCart', __FILE__ ); + $this->requests = array(); + $this->client->settings()->add_page( array() ); + add_filter( 'pre_http_request', array( $this, 'stub' ), 10, 3 ); + } + + public function tear_down() { + remove_filter( 'pre_http_request', array( $this, 'stub' ), 10 ); + $_POST = array(); + parent::tear_down(); + } + + /** + * Record each request and answer it with a 200. + * + * @param false|array|\WP_Error $pre Unused, required by the filter. + * @param array $args Request arguments. + * @param string $url Request URL. + * + * @return array + */ + public function stub( $pre, $args, $url ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + $this->requests[] = $args['method'] . ' ' . preg_replace( '#^https?://[^/]+/#', '', strtok( $url, '?' ) ); + + return array( + 'headers' => array(), + 'cookies' => array(), + 'response' => array( + 'code' => 200, + 'message' => '', + ), + 'body' => wp_json_encode( array( 'id' => 'act_mine' ) ), + ); + } + + public function test_form_never_prints_the_saved_key() { + $this->client->settings()->license_key = 'SECRET_KEY'; + + ob_start(); + $this->client->settings()->settings_output(); + $html = ob_get_clean(); + + $this->assertStringNotContainsString( 'SECRET_KEY', $html ); + $this->assertStringContainsString( 'Leave blank to use the saved key', $html ); + } + + public function test_deactivation_ignores_a_posted_activation_id() { + $this->client->settings()->license_key = 'KEY'; + $this->client->settings()->activation_id = 'act_mine'; + + $_POST = array( + 'submit' => 'Deactivate License', + '_action' => 'deactivate', + '_nonce' => wp_create_nonce( $this->client->name ), + 'activation_id' => 'act_someone_else', + ); + + $this->client->settings()->license_form_submit(); + + $this->assertSame( array( 'DELETE v1/public/activations/act_mine' ), $this->requests ); + } + + public function test_redirect_keeps_query_args() { + ob_start(); + $this->client->settings()->redirect( 'https://example.com/wp-admin/admin.php?page=my-plugin&tab=license' ); + $html = ob_get_clean(); + + $this->assertStringContainsString( 'page=my-plugin&tab=license', $html ); + $this->assertStringNotContainsString( '&', $html ); + } +}