From e7dbf0c8ff5723e4c14a761db3a634d36685c441 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Fri, 24 Jul 2026 12:25:13 +1000 Subject: [PATCH] fix: flush the update cache on every license removal path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activation and deactivation already flushed the cached update info, but three paths that change license state did not: 1. Access Pass siblings. maybe_auto_activate_license()/maybe_auto_deactivate_license() called update_license_info() alone, so every add-on covered by the pass kept version info fetched under the old key (no package) for up to the 3 hour TTL, plus its stale update_plugins entry. 2. Clearing the key in the settings form. Unlike the Deactivate button, the empty-key branch of maybe_active_licenses() left the cached package in place. 3. The Multisite network cache. delete_version_info_cache() only removes the per-site option, so the gpdf_sl_net_* package a site promoted stayed borrowable through maybe_apply_network_package() for its 3 day TTL — a site that had lost its license could still hand out a download URL to the network. Withdrawing the network package needs a lost license and ownership of the cached copy. Lost means the key was removed or the store rejected it outright: expired, revoked, disabled, missing, invalid, site_inactive, item_name_mismatch, invalid_item_id, no_activations_left — all of which arrive with the key still populated, so the status is the only signal. `error` and `rate_limit` are excluded, because they are written on a WP_Error, timeout or 429, and treating a failed request as a verdict would strip a package every subsite depends on whenever the licensing server is briefly unreachable. Ownership is tracked by recording the promoting blog id alongside the package: a promotion made by another site belongs to a site that is still licensed, and must outlive this one. Two guards keep the new flushes off no-op paths. The sibling flush only fires when the key or status actually moved, since a hardcoded GPDF_LICENSE_KEY re-fires the activation action every few hours and would otherwise force a fresh remote request per add-on each cycle. The settings-form flush only fires when a key was previously stored, since the licensing tab posts an empty field for every registered add-on on an unrelated save. Also corrects Model_Uninstall::remove_plugin_network_options()'s docblock, which described the network cache as a single option. There is one per extension; the wildcard delete is what makes the cleanup complete. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + src/Helper/Helper_Abstract_Addon.php | 37 +++++++- .../Licensing/EDD_SL_Plugin_Updater.php | 48 +++++++++- src/Model/Model_Settings.php | 6 ++ src/Model/Model_Uninstall.php | 4 +- .../Licensing/Test_EDD_SL_Plugin_Updater.php | 87 ++++++++++++++++++ .../unit-tests/Model/Test_Model_Settings.php | 45 ++++++++++ tests/phpunit/unit-tests/test-addon.php | 90 +++++++++++++++++++ 8 files changed, 313 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 332ddbf93..939b6b48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ * 🐞 Bug: Show an error message when the license deactivation request fails with a server or authentication error, instead of leaving the spinner running * 🐞 Bug: Ignore repeat clicks of the Deactivate button while a request is in progress * 🐞 Bug: Clearing a license key from the settings now updates the license status straight away +* 🐞 Bug: Delete all cached update information when a license key is deactivated/cleared from the settings page, or is rejected by the licensing server * 🐞 Bug: Fix PHP fatal error when doing an update check and the licensing server is unavailable * 🐞 Bug: Stop repeated update requests if the licensing server returns an empty response * 💻 Developer: Add `GPDFAPI::get_pdf_filename( $entry_id, $pdf_id, $include_extension )` to get the filename of a PDF diff --git a/src/Helper/Helper_Abstract_Addon.php b/src/Helper/Helper_Abstract_Addon.php index 257c9693d..42e1534c5 100644 --- a/src/Helper/Helper_Abstract_Addon.php +++ b/src/Helper/Helper_Abstract_Addon.php @@ -710,6 +710,22 @@ public function update_license_info( $license_info, $use_database = false ) { $this->options->update_settings( $settings ); } + /** + * Whether incoming license info differs from what this add-on already holds + * + * The incoming key is compared against the raw `$license_key` property, not get_license_key(), which folds in the + * `GPDF_LICENSE_KEY` constant and so reads identically on both sides however the add-on's own key changed. + * + * @param array $license_info + * + * @return bool + * + * @since 6.16.0 + */ + private function license_info_has_changed( $license_info ) { + return $license_info['license'] !== $this->license_key || $license_info['status'] !== $this->license_key_status; + } + /** * Remove the license info and keys from the settings * @@ -1169,7 +1185,16 @@ public function maybe_auto_activate_license( $response, $addon, $use_database = return; } - $this->update_license_info( $addon->get_license_info(), $use_database ); + $license_info = $addon->get_license_info(); + $has_changed = $this->license_info_has_changed( $license_info ); + + $this->update_license_info( $license_info, $use_database ); + + /* Cached update info was fetched under the old key, so it holds no package for the new one. A hardcoded key + re-fires this action every few hours, so skip the flush when nothing actually moved. */ + if ( $has_changed ) { + $this->flush_update_cache(); + } $this->license_auto_activated = true; } @@ -1251,7 +1276,14 @@ public function maybe_auto_deactivate_license( $response, $addon ) { return; } - $this->update_license_info( $addon->get_license_info(), true ); + $license_info = $addon->get_license_info(); + $has_changed = $this->license_info_has_changed( $license_info ); + + $this->update_license_info( $license_info, true ); + + if ( $has_changed ) { + $this->flush_update_cache(); + } $this->license_auto_deactivated = true; } @@ -1269,5 +1301,6 @@ public function flush_update_cache() { $this->plugin_updater->delete_version_info_cache(); $this->plugin_updater->delete_transient_plugin_info(); + $this->plugin_updater->delete_network_version_info_cache(); } } diff --git a/src/Helper/Licensing/EDD_SL_Plugin_Updater.php b/src/Helper/Licensing/EDD_SL_Plugin_Updater.php index 954f40b5f..145e2244d 100644 --- a/src/Helper/Licensing/EDD_SL_Plugin_Updater.php +++ b/src/Helper/Licensing/EDD_SL_Plugin_Updater.php @@ -39,6 +39,22 @@ class EDD_SL_Plugin_Updater { /* The network-shared package outlives the per-site 3h check cache so a quiet licensed site can't let it lapse */ protected const NETWORK_CACHE_TTL = 3 * DAY_IN_SECONDS; + /* + * Drawn from Helper_Data::addon_license_responses(). `error` and `rate_limit` are deliberately absent — a failed or + * throttled request is not a verdict on the license, so it must not strip a package other sites depend on. + */ + protected const UNENTITLED_LICENSE_STATUSES = [ + 'expired', + 'revoked', + 'disabled', + 'missing', + 'invalid', + 'site_inactive', + 'item_name_mismatch', + 'invalid_item_id', + 'no_activations_left', + ]; + /** * Class constructor. * @@ -735,11 +751,12 @@ public function set_version_info_cache( $value = '', $cache_key = '' ) { /* * Promote the package to a network option so any site on the Multisite can install the update. The store can * return a package URL even when the license is inactive (that URL errors on access), so only an active license - * is allowed to share its package. + * is allowed to share its package. The promoting site is recorded so it alone can withdraw the package later. */ if ( is_multisite() && $this->is_license_active() && is_object( $value ) && ! empty( $value->package ) ) { $network_data = $data; $network_data['timeout'] = time() + self::NETWORK_CACHE_TTL; + $network_data['blog_id'] = get_current_blog_id(); update_site_option( $this->get_network_cache_key(), $network_data ); } } @@ -761,6 +778,35 @@ public function delete_version_info_cache( $cache_key = '' ) { return delete_option( $cache_key ); } + /** + * Withdraw the package this site shared with the network, so a removed license stops backing network-wide downloads + * + * Ownership counts as much as the license state: a package promoted by another site belongs to a site that is still + * licensed, and must outlive this one losing its own. + * + * @return bool + * + * @since 6.16.0 + */ + public function delete_network_version_info_cache() { + if ( ! is_multisite() ) { + return false; + } + + $lost_license = empty( $this->api_data['license'] ) || in_array( $this->license_status, self::UNENTITLED_LICENSE_STATUSES, true ); + if ( ! $lost_license ) { + return false; + } + + $cache_key = $this->get_network_cache_key(); + $cache = get_site_option( $cache_key ); + if ( ! is_array( $cache ) || (int) ( $cache['blog_id'] ?? 0 ) !== get_current_blog_id() ) { + return false; + } + + return delete_site_option( $cache_key ); + } + /** * Delete the cached update info without removing the entire update plugin data * diff --git a/src/Model/Model_Settings.php b/src/Model/Model_Settings.php index 320bb3749..87a17daf3 100644 --- a/src/Model/Model_Settings.php +++ b/src/Model/Model_Settings.php @@ -308,6 +308,12 @@ public function maybe_active_licenses( $input ) { ] ); + /* Clearing the field removes the license as surely as the Deactivate button does — drop the cached + package. Every add-on posts an empty field on an unrelated save, so only flush when a key was set. */ + if ( ! empty( $settings[ $option_key ] ) ) { + $addon->flush_update_cache(); + } + continue; } diff --git a/src/Model/Model_Uninstall.php b/src/Model/Model_Uninstall.php index ab39c1b0c..3ef483485 100644 --- a/src/Model/Model_Uninstall.php +++ b/src/Model/Model_Uninstall.php @@ -141,8 +141,8 @@ public function remove_plugin_options() { /** * Remove the network-shared license package cache stored in sitemeta on a Multisite * - * The per-site caches removed by remove_plugin_options() live in each site's options table; the network cache - * (see EDD_SL_Plugin_Updater::get_network_cache_key()) is a single network option, so it's cleaned up once here. + * Every extension keeps its own entry (see EDD_SL_Plugin_Updater::get_network_cache_key()), but all are + * network-scoped rather than per-site, so one wildcard pass clears them without walking the site list. * * @since 6.16.0 */ diff --git a/tests/phpunit/unit-tests/Helper/Licensing/Test_EDD_SL_Plugin_Updater.php b/tests/phpunit/unit-tests/Helper/Licensing/Test_EDD_SL_Plugin_Updater.php index e04e520e6..d35eb2e74 100644 --- a/tests/phpunit/unit-tests/Helper/Licensing/Test_EDD_SL_Plugin_Updater.php +++ b/tests/phpunit/unit-tests/Helper/Licensing/Test_EDD_SL_Plugin_Updater.php @@ -1065,4 +1065,91 @@ public function test_get_repo_api_data_does_not_borrow_expired_network_package() $this->assertSame( '0.2', $result->new_version ); $this->assertEmpty( $result->package ); } + + public function test_delete_network_version_info_cache_withdraws_own_promotion() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + delete_site_option( $this->class->get_network_cache_key() ); + $this->class->set_license_status( 'valid' ); + + $licensed = new \stdClass(); + $licensed->new_version = '0.2'; + $licensed->package = 'https://store.com/download/licensed-123'; + $this->class->set_version_info_cache( $licensed ); + + $cache = get_site_option( $this->class->get_network_cache_key() ); + $this->assertSame( get_current_blog_id(), $cache['blog_id'] ); + + /* A failed or throttled check is not a verdict on the license, so the shared package stands */ + foreach ( [ 'error', 'rate_limit' ] as $status ) { + $this->class->set_license_status( $status ); + $this->assertFalse( $this->class->delete_network_version_info_cache(), $status ); + } + + /* Removing the key does withdraw it */ + $this->class->set_license_key( '' ); + $this->assertTrue( $this->class->delete_network_version_info_cache() ); + $this->assertFalse( get_site_option( $this->class->get_network_cache_key() ) ); + } + + /** + * @dataProvider providerUnentitledLicenseStatus + */ + public function test_delete_network_version_info_cache_withdraws_on_store_rejection( $status ) { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + delete_site_option( $this->class->get_network_cache_key() ); + $this->class->set_license_status( 'valid' ); + + $licensed = new \stdClass(); + $licensed->new_version = '0.2'; + $licensed->package = 'https://store.com/download/licensed-123'; + $this->class->set_version_info_cache( $licensed ); + + /* The key stays populated on a rejection, so the status is the only signal the entitlement ended */ + $this->class->set_license_status( $status ); + + $this->assertTrue( $this->class->delete_network_version_info_cache() ); + $this->assertFalse( get_site_option( $this->class->get_network_cache_key() ) ); + } + + public function providerUnentitledLicenseStatus() { + return [ + [ 'expired' ], + [ 'revoked' ], + [ 'disabled' ], + [ 'missing' ], + [ 'invalid' ], + [ 'site_inactive' ], + [ 'item_name_mismatch' ], + [ 'invalid_item_id' ], + [ 'no_activations_left' ], + ]; + } + + public function test_delete_network_version_info_cache_keeps_another_sites_promotion() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + /* Another site promoted this package, so its license is still active and the package must survive */ + $network = new \stdClass(); + $network->new_version = '0.2'; + $network->package = 'https://store.com/download/licensed-123'; + update_site_option( + $this->class->get_network_cache_key(), + [ 'timeout' => strtotime( '+3 hours' ), 'value' => wp_json_encode( $network ), 'blog_id' => get_current_blog_id() + 1 ] + ); + + $this->class->set_license_key( '' ); + + $this->assertFalse( $this->class->delete_network_version_info_cache() ); + $this->assertNotEmpty( get_site_option( $this->class->get_network_cache_key() ) ); + + delete_site_option( $this->class->get_network_cache_key() ); + } } diff --git a/tests/phpunit/unit-tests/Model/Test_Model_Settings.php b/tests/phpunit/unit-tests/Model/Test_Model_Settings.php index bc56c0007..994d7e46c 100644 --- a/tests/phpunit/unit-tests/Model/Test_Model_Settings.php +++ b/tests/phpunit/unit-tests/Model/Test_Model_Settings.php @@ -605,6 +605,51 @@ public function test_maybe_active_licenses_clears_in_memory_status_on_empty_key( $this->assertSame( '', $this->addon->get_license_key() ); } + public function test_maybe_active_licenses_flushes_update_cache_on_empty_key() { + do_action( 'init' ); + + $options = \GPDFAPI::get_options_class(); + $slug = $this->addon->get_slug(); + + $settings = $options->get_settings(); + $settings[ "license_$slug" ] = 'old-key'; + $options->update_settings( $settings ); + + $this->addon->update_license_info( [ 'license' => 'old-key', 'status' => 'active', 'message' => 'ok' ] ); + + $updater = $this->addon->get_plugin_updater(); + update_option( + $updater->get_cache_key(), + [ 'timeout' => strtotime( '+3 hours' ), 'value' => wp_json_encode( (object) [ 'new_version' => '2.0' ] ) ] + ); + + $this->model->maybe_active_licenses( [ "license_$slug" => '' ] ); + + /* The cached update info was fetched with the now-removed key, so it must not outlive it */ + $this->assertFalse( $updater->get_cached_version_info() ); + + delete_option( $updater->get_cache_key() ); + } + + public function test_maybe_active_licenses_skips_flush_for_unlicensed_addon() { + do_action( 'init' ); + + $slug = $this->addon->get_slug(); + $updater = $this->addon->get_plugin_updater(); + + update_option( + $updater->get_cache_key(), + [ 'timeout' => strtotime( '+3 hours' ), 'value' => wp_json_encode( (object) [ 'new_version' => '2.0' ] ) ] + ); + + /* Every add-on posts an empty license field on an unrelated save; one that never had a key keeps its cache */ + $this->model->maybe_active_licenses( [ "license_$slug" => '' ] ); + + $this->assertIsObject( $updater->get_cached_version_info() ); + + delete_option( $updater->get_cache_key() ); + } + public function test_licensing_bulk_license_check_skips_malformed_and_unknown_items() { do_action( 'init' ); diff --git a/tests/phpunit/unit-tests/test-addon.php b/tests/phpunit/unit-tests/test-addon.php index f9530b064..53e6efdd6 100644 --- a/tests/phpunit/unit-tests/test-addon.php +++ b/tests/phpunit/unit-tests/test-addon.php @@ -737,6 +737,96 @@ public function test_maybe_auto_deactivate_license_ignores_non_addon_arg() { $this->assertFalse( $this->addon->has_license_auto_deactivated() ); } + + /** + * The sibling's cached update info was fetched unlicensed (so it holds no package) and must not survive the + * Access Pass adopting a key. + * + * @since 6.16.0 + */ + public function test_maybe_auto_activate_license_flushes_update_cache() { + $this->addon->set_edd_download_id( 10 ); + $this->addon2->set_edd_download_id( 20 ); + + /* Seed first — init() re-reads the license info from the database, overwriting the in-memory copy */ + $updater = $this->seed_update_cache( $this->addon2 ); + + $this->addon->update_license_info( [ 'license' => 'AP-KEY', 'status' => 'active', 'message' => 'ok' ] ); + + $response = [ 'response' => [ 'code' => 200 ], 'body' => wp_json_encode( [ 'license' => 'valid', 'products' => [ 10, 20 ] ] ) ]; + $this->addon2->maybe_auto_activate_license( $response, $this->addon, false ); + + $this->assertFalse( $updater->get_cached_version_info() ); + + $this->addon->delete_license_info(); + $this->addon2->delete_license_info(); + } + + /** + * @since 6.16.0 + */ + public function test_maybe_auto_deactivate_license_flushes_update_cache() { + $this->addon->set_edd_download_id( 10 ); + $this->addon2->set_edd_download_id( 20 ); + $this->addon->delete_license_info(); + + $updater = $this->seed_update_cache( $this->addon2 ); + + $this->addon2->update_license_info( [ 'license' => 'AP-KEY', 'status' => 'active', 'message' => 'ok' ] ); + + $response = [ 'response' => [ 'code' => 200 ], 'body' => wp_json_encode( [ 'license' => 'deactivated', 'products' => [ 10, 20 ] ] ) ]; + $this->addon2->maybe_auto_deactivate_license( $response, $this->addon ); + + $this->assertFalse( $updater->get_cached_version_info() ); + + $this->addon2->delete_license_info(); + } + + /** + * A store rejection arrives with the key still populated, so the status has to carry the withdrawal end to end. + * + * @since 6.16.0 + */ + public function test_rejected_license_response_withdraws_network_package() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + $this->addon->init(); + do_action( 'init' ); + + $updater = $this->addon->get_plugin_updater(); + $package = (object) [ 'new_version' => '2.0', 'package' => 'https://store.com/download/123' ]; + + update_site_option( + $updater->get_network_cache_key(), + [ 'timeout' => strtotime( '+3 days' ), 'value' => wp_json_encode( $package ), 'blog_id' => get_current_blog_id() ] + ); + + $response = [ 'response' => [ 'code' => 200 ], 'body' => wp_json_encode( [ 'license' => 'expired', 'expires' => '2020-01-01 00:00:00' ] ) ]; + $this->addon->update_license_status_from_response( 'unchanged-key', $response ); + + $this->assertSame( 'expired', $this->addon->get_license_status() ); + $this->assertFalse( get_site_option( $updater->get_network_cache_key() ) ); + } + + /** + * Boot the add-on's updater and prime its version-info cache + * + * @return \GFPDF\Helper\Licensing\EDD_SL_Plugin_Updater + */ + private function seed_update_cache( $addon ) { + $addon->init(); + do_action( 'init' ); + + $updater = $addon->get_plugin_updater(); + update_option( + $updater->get_cache_key(), + [ 'timeout' => strtotime( '+3 hours' ), 'value' => wp_json_encode( (object) [ 'new_version' => '2.0', 'package' => '' ] ) ] + ); + + return $updater; + } } /**