diff --git a/gravity-pdf-updater.php b/gravity-pdf-updater.php index c615a7fb5..6787b3a98 100644 --- a/gravity-pdf-updater.php +++ b/gravity-pdf-updater.php @@ -25,18 +25,31 @@ add_action( 'init', function () { - new EDD_SL_Plugin_Updater( + $plugin_updater = new EDD_SL_Plugin_Updater( GPDF_API_URL, GPDF_PLUGIN_FILE, [ - 'version' => PDF_EXTENDED_VERSION, - 'item_id' => 137043, - 'license' => md5( site_url() ), - 'author' => 'Blue Liquid Designs', - 'beta' => false, + 'version' => PDF_EXTENDED_VERSION, + 'item_name' => 'Gravity PDF', + 'item_id' => 137043, + 'license' => md5( site_url() ), + 'author' => 'Blue Liquid Designs', + 'beta' => false, + 'wp_override' => current_user_can( 'update_plugins' ) && ! empty( $_GET['force-check'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended ] ); - } + + $plugin_updater->init(); + + /* Only make plugin updater available when fully initialized */ + if ( ! class_exists( 'GPDFAPI' ) ) { + return; + } + + $data = \GPDFAPI::get_data_class(); + $data->updater = $plugin_updater; + }, + 1 ); /** @@ -45,8 +58,8 @@ function () { add_action( 'http_api_debug', function ( $response, $context, $class_object, $parsed_args, $url ) { - /* Log only Gravity PDF requests */ - if ( $url !== GPDF_API_URL ) { + /* Log only Gravity PDF requests. The self-update check posts to a trailingslashit()'d URL, so normalise both sides. */ + if ( untrailingslashit( $url ) !== untrailingslashit( GPDF_API_URL ) ) { return; } @@ -57,27 +70,23 @@ function ( $response, $context, $class_object, $parsed_args, $url ) { $logger = \GPDFAPI::get_log_class(); - $request_body = $parsed_args['body'] ?? []; - if ( isset( $request_body['license'] ) && is_string( $request_body['license'] ) && strlen( $request_body['license'] ) > 2 ) { - /* mask the license key, if exists */ - $license = $request_body['license']; - $request_body['license'] = substr( $license, 0, 1 ) . str_repeat( '*', strlen( $license ) - 2 ) . substr( $license, -1, 1 ); - } - $logger->notice( 'Gravity PDF License Check API Request', [ 'url' => $url, 'method' => $parsed_args['method'] ?? '', - 'body' => $request_body, + 'body' => $parsed_args['body'] ?? [], ] ); - $response_code = wp_remote_retrieve_response_code( $response ); + $response_code = wp_remote_retrieve_response_code( $response ); + $response_body = wp_remote_retrieve_body( $response ); + $response_body_json = json_decode( $response_body, true ); + $response_context = [ 'status' => $response_code, - 'headers' => wp_remote_retrieve_headers( $response ), - 'body' => wp_remote_retrieve_body( $response ), + 'headers' => (array) wp_remote_retrieve_headers( $response ), + 'body' => $response_body_json ?? $response_body, ]; if ( $response_code >= 300 ) { @@ -121,15 +130,15 @@ function ( $plugin_file, $plugin_data ) { printf( '', - esc_attr( $plugin_data['slug'] ?? '' ), - esc_attr( $plugin_data['plugin'] ?? '' ), + esc_attr( 'gravity-forms-pdf-extended' ), + esc_attr( 'gravity-forms-pdf-extended/pdf.php' ), 'inactive' ); echo ''; echo '

'; - echo esc_html__( 'This is the non-canonical release of Gravity PDF.', 'gravity-pdf' ); + echo esc_html__( 'This is the non-canonical release of Gravity PDF which can be deleted.', 'gravity-pdf' ); echo '

'; echo ''; diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 81b7f6129..47a9cb876 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -46,6 +46,7 @@ + /tests/* @@ -97,6 +98,5 @@ /vendor/* /vendor_prefixed/* /node_modules/* - /src/helper/licensing/EDD_SL_Plugin_Updater.php /.php-scoper/* \ No newline at end of file diff --git a/src/Controller/Controller_Activation.php b/src/Controller/Controller_Activation.php index 3e062d16e..03d872853 100644 --- a/src/Controller/Controller_Activation.php +++ b/src/Controller/Controller_Activation.php @@ -70,6 +70,8 @@ public static function deactivation() { /* Remove our scheduled tasks */ wp_clear_scheduled_hook( 'gfpdf_cleanup_tmp_dir' ); + wp_clear_scheduled_hook( 'gfpdf_network_update_check' ); + wp_clear_scheduled_hook( 'gfpdf_bulk_license_check' ); /* Flush caches */ $templates = \GPDFAPI::get_templates_class(); diff --git a/src/Controller/Controller_Settings.php b/src/Controller/Controller_Settings.php index b186c84f6..3b0636eed 100644 --- a/src/Controller/Controller_Settings.php +++ b/src/Controller/Controller_Settings.php @@ -170,6 +170,27 @@ public function add_actions() { * Add AJAX Action Endpoints */ add_action( 'wp_ajax_gfpdf_deactivate_license', [ $this->model, 'process_license_deactivation' ] ); + + /* Schedule License Check for all add-ons */ + add_action( + 'init', + function () { + if ( empty( $this->data->addon ) ) { + return; + } + + if ( $this->misc->is_secondary_network_site( PDF_PLUGIN_BASENAME ) ) { + return; + } + + add_action( 'gfpdf_bulk_license_check', [ $this->model, 'licensing_bulk_license_check' ] ); + if ( ! wp_next_scheduled( 'gfpdf_bulk_license_check' ) ) { + wp_schedule_single_event( strtotime( '+1 week' ), 'gfpdf_bulk_license_check' ); + } + } + ); + + $this->maybe_schedule_network_update_check(); } /** @@ -200,6 +221,8 @@ public function add_filters() { /* Register add-ons for licensing page */ add_filter( 'gfpdf_settings_licenses', [ $this->model, 'register_addons_for_licensing' ] ); add_filter( 'gfpdf_settings_license_sanitize', [ $this->model, 'maybe_active_licenses' ] ); + add_filter( 'gpdf_sl_plugin_updater_api_params', [ $this->model, 'licensing_bulk_get_version_api_params' ] ); + add_filter( 'gpdf_sl_plugin_updater_api_response', [ $this->model, 'licensing_bulk_get_version_api_response' ], 10, 3 ); } /** @@ -284,4 +307,32 @@ public function disable_tools_on_view_cap( $nav ) { return $nav; } + + /** + * On a Multisite installation where Gravity PDF isn't network/primary site activated, + * add a scheduled task to display an update nag in the network admin on a best-effort basis + * + * @return void + * + * @since 6.16.0 + */ + protected function maybe_schedule_network_update_check() { + if ( ! is_multisite() || is_main_site() ) { + return; + } + + /* Network-activated installs already receive update checks through the normal flow */ + if ( $this->misc->is_secondary_network_site( PDF_PLUGIN_BASENAME ) ) { + return; + } + + add_action( 'gfpdf_network_update_check', [ $this->model, 'run_network_update_check' ] ); + + /* skip if event already scheduled; run_network_update_check() re-arms it each cycle */ + if ( wp_next_scheduled( 'gfpdf_network_update_check' ) ) { + return; + } + + $this->model->schedule_network_update_check(); + } } diff --git a/src/Controller/Controller_Upgrade_Routines.php b/src/Controller/Controller_Upgrade_Routines.php index 7ae947d1e..9832e4720 100644 --- a/src/Controller/Controller_Upgrade_Routines.php +++ b/src/Controller/Controller_Upgrade_Routines.php @@ -58,6 +58,10 @@ public function maybe_run_upgrade( string $old_version, string $current_version if ( version_compare( $current_version, '6.13.2', '>=' ) && version_compare( $old_version, '6.13.2', '<' ) ) { $this->fix_tmp_folder_permissions(); } + + if ( version_compare( $current_version, '6.16.0', '>=' ) && version_compare( $old_version, '6.16.0', '<' ) ) { + $this->remove_legacy_update_cache(); + } } /** @@ -142,4 +146,21 @@ function ( $current, $key, $iterator ) { } } } + + /** + * Remove Gravity PDF's legacy edd_sl_* update cache options left behind by the previous plugin updater + * + * @since 6.16.0 + */ + protected function remove_legacy_update_cache() { + global $wpdb; + + $wpdb->query( "DELETE FROM $wpdb->options WHERE option_name LIKE 'edd_sl_%' AND option_value LIKE '%gravity-pdf%'" ); + + /* The failure-backoff option stores a bare timestamp, so the value filter above can't match it — target both + the historical (≤6.14.x) and the 6.15.0 API hosts by exact name. 6.15.0's key was autoloaded, so a site that + ever hit an API failure would otherwise carry a stale autoloaded option forever. */ + delete_option( 'edd_sl_failed_http_' . md5( 'https://gravitypdf.com?api=1' ) ); + delete_option( 'edd_sl_failed_http_' . md5( GPDF_API_URL ) ); + } } diff --git a/src/Helper/Helper_Abstract_Addon.php b/src/Helper/Helper_Abstract_Addon.php index 14d600519..257c9693d 100644 --- a/src/Helper/Helper_Abstract_Addon.php +++ b/src/Helper/Helper_Abstract_Addon.php @@ -2,6 +2,7 @@ namespace GFPDF\Helper; +use GFPDF\Helper\Licensing\EDD_SL_Plugin_Updater; use Psr\Log\LoggerInterface; /** @@ -25,35 +26,35 @@ abstract class Helper_Abstract_Addon { * * @since 4.2 */ - private $slug; + protected $slug; /** * @var string The add-on name (should match the name/title used in EDD) * * @since 4.2 */ - private $name; + protected $name; /** * @var string The add-on author * * @since 4.2 */ - private $author; + protected $author; /** * @var string The add-on version * * @since 4.2 */ - private $version; + protected $version; /** * @var string The add-on mail file path * * @since 4.2 */ - private $addon_path_main_plugin_file; + protected $addon_path_main_plugin_file; /** * Holds our registered objects @@ -124,6 +125,42 @@ abstract class Helper_Abstract_Addon { */ protected $use_settings_prefix = false; + /** + * @var EDD_SL_Plugin_Updater + * @since 6.16.0 + */ + protected $plugin_updater; + + /** + * @var string The current license key for this addon + * @since 6.16.0 + */ + protected $license_key = ''; + + /** + * @var string The current license key status (retrieved from the API) for this addon + * @since 6.16.0 + */ + protected $license_key_status = ''; + + /** + * @var string The current license key message for this addon (based on the status) + * @since 6.16.0 + */ + protected $license_key_message = ''; + + /** + * @var bool Whether the addon activated the license based on another addon activation + * @since 6.16.0 + */ + protected $license_auto_activated = false; + + /** + * @var bool Whether the addon deactivated the license based on another addon deactivation + * @since 6.16.0 + */ + protected $license_auto_deactivated = false; + /** * Helper_Abstract_Addon constructor. * @@ -252,6 +289,19 @@ final public function get_addon_documentation_slug() { return $this->addon_documentation_slug; } + /** + * @return EDD_SL_Plugin_Updater|null + * @since 6.16.0 + */ + public function get_plugin_updater() { + $updater = $this->plugin_updater; + if ( ! $updater ) { + _doing_it_wrong( __METHOD__, 'This method should not be called before the "init" hook (priority 1)', '6.16.0' ); + } + + return $updater; + } + /** * Setup the add-on licensing and initialise any classes * @@ -261,13 +311,24 @@ final public function get_addon_documentation_slug() { */ public function init( $classes = [] ) { + /* Get and store the license information from the database */ + $this->get_license_info( true ); + /* - * Register our plugin updater on the admin initialisation action - * - * @Internal Due to WordPress.org rules we cannot initialisation the updater code in the core plugin - * Add-ons have to initialise this functionality via GFPDF\Helper\Licensing\EDD_SL_Plugin_Updater + * Register our plugin updater */ - add_action( 'init', [ $this, 'plugin_updater' ] ); + $central_plugin_updater = function () { + $this->central_plugin_updater(); + }; + + add_action( 'init', $central_plugin_updater, 1 ); + + /* Maybe auto-activate hardcoded license */ + $maybe_activate_hardcoded_license = function () { + $this->maybe_activate_hardcoded_license(); + }; + + add_action( 'init', $maybe_activate_hardcoded_license, 2 ); /* * Automatically register our addon with the main plugin to enable license management in the UI @@ -281,22 +342,17 @@ public function init( $classes = [] ) { add_filter( 'gfpdf_settings_extensions', [ $this, 'register_addon_fields' ] ); } - /* - * Automatically schedule license checks weekly - */ - add_action( 'admin_init', [ $this, 'maybe_schedule_license_check' ] ); + /* Add listener for now-deprecated individual licence check (handled in bulk in Controller_Settings) */ add_action( 'gfpdf_' . $this->get_slug() . '_license_check', [ $this, 'schedule_license_check' ] ); + /* Add listener for other license activation/deactivation */ + add_action( 'gfpdf_addon_post_license_activation', [ $this, 'maybe_auto_activate_license' ], 10, 3 ); + add_action( 'gfpdf_addon_post_license_deactivation', [ $this, 'maybe_auto_deactivate_license' ], 10, 2 ); + /* * Include info on plugin listing */ - add_action( - 'after_plugin_row_' . plugin_basename( $this->get_main_plugin_file() ), - [ - $this, - 'license_registration', - ] - ); + add_action( 'after_plugin_row_' . plugin_basename( $this->get_main_plugin_file() ), [ $this, 'license_registration' ] ); add_filter( 'plugin_row_meta', [ $this, 'plugin_row_meta' ], 10, 2 ); /* @@ -329,15 +385,12 @@ function ( $class_object ) { /** * This method handles the add-on update code * - * Due to WordPress.org rules we cannot initialisation the updater code in the core plugin so add-ons that utilise - * this class need to handle that code themselves. - * * Official Gravity PDF add-ons should initialise the GFPDF\Helper\Licensing\EDD_SL_Plugin_Updater class * when the add-on license status is set to "active". You can check the status of the plugin * using the following: * * $license_info = $this->get_license_info(); - * if ( $license_info['status'] !== 'active' ) { + * if ( in_array( $this->get_license_status(), [ 'active', 'valid' ], true ) ) { * return; * } * @@ -357,14 +410,85 @@ function ( $class_object ) { * * @return void * @since 4.2 + * @deprecated 6.16.0 Use self::central_plugin_updater() + */ + public function plugin_updater() {} + + /** + * @return array + * @since 6.16.0 + */ + public function get_default_api_params() { + return [ + 'version' => $this->get_version(), + 'license' => $this->get_license_key(), + 'item_name' => $this->get_short_name(), + 'item_id' => $this->get_edd_download_id(), + 'author' => $this->get_author(), + 'beta' => false, + ]; + } + + /** + * The central add-on update initializer * + * @return void + * @since 6.16.0 */ - abstract public function plugin_updater(); + protected function central_plugin_updater() { + $this->plugin_updater = new EDD_SL_Plugin_Updater( + $this->data->store_url, + $this->get_main_plugin_file(), + $this->get_default_api_params() + ); + + $this->plugin_updater->set_license_status( $this->get_license_status() ); + $this->plugin_updater->init(); + } /** - * Register the add-on with Gravity PDF + * Activate a hardcoded license key (set via the GPDF_LICENSE_KEY constant) on an admin request. * - * @Internal If you don't want the add-on licensing handled automatically in the UI override this method + * Retries while the stored status isn't active/valid so a transient API failure on the first attempt self-heals, + * and re-activates when the constant key is rotated. A short backoff keeps a rejected or unreachable key from + * hitting the licensing API on every admin request. + * + * @return void + * @since 6.16.0 + */ + protected function maybe_activate_hardcoded_license() { + $hardcoded_license = $this->get_license_key_from_constant(); + if ( ! $hardcoded_license || $this->license_auto_activated || ! is_admin() ) { + return; + } + + /* On a network-activated Multisite, only the primary site activates the hardcoded license */ + if ( \GPDFAPI::get_misc_class()->is_secondary_network_site( PDF_PLUGIN_BASENAME ) ) { + return; + } + + $key_changed = $hardcoded_license !== $this->license_key; + $is_active = in_array( $this->get_license_status(), [ 'active', 'valid' ], true ); + + if ( ! $key_changed && $is_active ) { + return; + } + + /* Back off retries for an unchanged, still-inactive key so a bad or unreachable key doesn't POST every request */ + $backoff = 'gfpdf_license_activation_' . $this->get_slug(); + if ( ! $key_changed && get_transient( $backoff ) ) { + return; + } + + $this->activate_license( $hardcoded_license, true ); + + if ( ! in_array( $this->get_license_status(), [ 'active', 'valid' ], true ) ) { + set_transient( $backoff, 1, 3 * HOUR_IN_SECONDS ); + } + } + + /** + * Register the add-on with Gravity PDF * * @since 4.2 */ @@ -522,24 +646,27 @@ final public function get_addon_setting_value( string $name, $fallback = '' ) { } /** - * Get the add-on license information stored in the database (if any) + * Get the add-on license information (if any) * - * @Internal If you don't want the add-on licensing handled automatically in the UI override this method + * @param bool $use_database Fetch license info from the database * * @since 4.2 + * @since 6.16.0 Get license info stored in the object */ - public function get_license_info() { - $settings = $this->options->get_settings(); - - $slug = $this->get_slug(); - $license = ( isset( $settings[ "license_$slug" ] ) ) ? $settings[ "license_$slug" ] : ''; - $status = ( isset( $settings[ "license_{$slug}_status" ] ) ) ? $settings[ "license_{$slug}_status" ] : ''; - $message = ( isset( $settings[ "license_{$slug}_message" ] ) ) ? $settings[ "license_{$slug}_message" ] : ''; + public function get_license_info( $use_database = false ) { + if ( $use_database ) { + $settings = $this->options->get_settings(); + + $slug = $this->get_slug(); + $this->license_key = $settings[ "license_$slug" ] ?? ''; + $this->license_key_status = $settings[ "license_{$slug}_status" ] ?? ''; + $this->license_key_message = $settings[ "license_{$slug}_message" ] ?? ''; + } $license_details = [ - 'license' => $license, - 'status' => $status, - 'message' => $message, + 'license' => $this->get_license_key(), + 'status' => $this->get_license_status(), + 'message' => $this->get_license_message(), ]; $this->log->notice( 'Get plugin license details', $license_details ); @@ -551,18 +678,32 @@ public function get_license_info() { * Update the add-on license information stored in the database * * @param array $license_info - * - * @Internal If you don't want the add-on licensing handled automatically in the UI override this method + * @param bool $use_database Whether to update the database or not. A DB update will auto-call Model_Settings::maybe_active_licenses(), which may not be ideal * * @since 4.2 + * @since 6.16.0 Added */ - public function update_license_info( $license_info ) { + public function update_license_info( $license_info, $use_database = false ) { + $this->license_key = $license_info['license'] ?? ''; + $this->license_key_status = $license_info['status'] ?? ''; + $this->license_key_message = $license_info['message'] ?? ''; + + /* Check the update has been initialized before setting the license key */ + if ( isset( $this->plugin_updater ) ) { + $this->plugin_updater->set_license_key( $this->license_key ); + $this->plugin_updater->set_license_status( $this->license_key_status ); + } + + if ( ! $use_database ) { + return; + } + $settings = $this->options->get_settings(); $slug = $this->get_slug(); - $settings[ "license_$slug" ] = $license_info['license']; - $settings[ "license_{$slug}_status" ] = $license_info['status']; - $settings[ "license_{$slug}_message" ] = $license_info['message']; + $settings[ "license_$slug" ] = $this->get_license_key(); + $settings[ "license_{$slug}_status" ] = $this->get_license_status(); + $settings[ "license_{$slug}_message" ] = $this->get_license_message(); $this->log->notice( 'Update plugin license details', $license_info ); @@ -575,12 +716,23 @@ public function update_license_info( $license_info ) { * @since 4.2 */ public function delete_license_info() { + $this->update_license_info( [] ); + + /* Check the update has been initialized before setting the license key */ + if ( isset( $this->plugin_updater ) ) { + $this->plugin_updater->set_license_key( '' ); + } + $settings = $this->options->get_settings(); $slug = $this->get_slug(); - unset( $settings[ "license_$slug" ] ); - unset( $settings[ "license_{$slug}_status" ] ); - unset( $settings[ "license_{$slug}_message" ] ); + unset( + $settings[ "license_$slug" ], + $settings[ "license_{$slug}_status" ], + $settings[ "license_{$slug}_message" ] + ); + + wp_clear_scheduled_hook( 'gfpdf_' . $slug . '_license_check' ); $this->log->notice( 'Delete plugin license details' ); @@ -593,7 +745,44 @@ public function delete_license_info() { * @since 4.2 */ final public function get_license_key() { - return $this->get_license_info()['license']; + $hardcoded_license = $this->get_license_key_from_constant(); + + return $hardcoded_license ?: $this->license_key; + } + + /** + * Get a Gravity PDF license key defined in the `GPDF_LICENSE_KEY` PHP constant + * + * @return false|string + * + * @since 6.16.0 + */ + final public function get_license_key_from_constant() { + $slug = $this->get_slug(); + + /** @var string|array $license_key */ + $license_key = defined( 'GPDF_LICENSE_KEY' ) ? GPDF_LICENSE_KEY : null; + $license_key = apply_filters( 'gfpdf_addon_hardcoded_license_key', $license_key, $slug, $this ); + + if ( empty( $license_key ) ) { + return false; + } + + /* universal license */ + if ( is_string( $license_key ) ) { + return $license_key; + } + + /* extension-specific license */ + if ( is_array( $license_key ) && isset( $license_key[ $slug ] ) ) { + return $license_key[ $slug ]; + } + + if ( is_array( $license_key ) && isset( $license_key['*'] ) ) { + return $license_key['*']; + } + + return false; } /** @@ -602,7 +791,7 @@ final public function get_license_key() { * @since 4.2 */ final public function get_license_status() { - return $this->get_license_info()['status']; + return $this->license_key_status; } /** @@ -611,7 +800,38 @@ final public function get_license_status() { * @since 4.2 */ final public function get_license_message() { - return $this->get_license_info()['message']; + return $this->license_key_message; + } + + /** + * Whether the addon activated the license based on another addon activation + * + * @return bool + * @since 6.16.0 + */ + final public function has_license_auto_activated() { + return $this->license_auto_activated; + } + + /** + * Whether the license key is controlled by the site rather than the settings form — a `GPDF_LICENSE_KEY` + * constant or an auto-activated Access Pass. Such a key is authoritative: a submitted value must be ignored. + * + * @return bool + * @since 6.16.0 + */ + final public function is_license_admin_managed() { + return (bool) $this->get_license_key_from_constant() || $this->has_license_auto_activated(); + } + + /** + * Whether the addon deactivated the license based on another addon deactivation. + * + * @return bool + * @since 6.16.0 + */ + final public function has_license_auto_deactivated() { + return $this->license_auto_deactivated; } /** @@ -621,6 +841,8 @@ final public function get_license_message() { * and 2. Need to clear the scheduled hook when the plugin is deactivated * * @since 4.2 + * + * @deprecated 6.16.0 Handled in bulk via Model_Settings::licensing_bulk_license_check() */ final public function maybe_schedule_license_check() { if ( ! wp_next_scheduled( 'gfpdf_' . $this->get_slug() . '_license_check' ) ) { @@ -631,15 +853,16 @@ final public function maybe_schedule_license_check() { /** * Makes an API call to check the status of the license and updates the license settings * - * @Internal If you don't want the add-on licensing handled automatically in the UI override this method - * * @since 4.2 */ public function schedule_license_check() { - $license_info = $this->get_license_info(); + /* On a network-activated Multisite, only the primary site runs the license check */ + if ( \GPDFAPI::get_misc_class()->is_secondary_network_site( PDF_PLUGIN_BASENAME ) ) { + return false; + } - /* If the license info is empty disable check */ - if ( empty( array_filter( $license_info ) ) ) { + /* If there's no license key disable the check */ + if ( empty( $this->get_license_key() ) ) { return false; } @@ -647,52 +870,112 @@ public function schedule_license_check() { $this->data->store_url, [ 'timeout' => 15, - 'body' => [ - 'edd_action' => 'check_license', - 'license' => $license_info['license'], - 'item_id' => $this->get_edd_download_id(), - 'item_name' => rawurlencode( $this->get_short_name() ), - 'url' => home_url(), - 'environment' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production', - ], + 'body' => array_merge( + [ 'edd_action' => 'check_license' ], + $this->get_default_api_params() + ), ] ); - /* If there was a problem with the request we'll try again in an hour */ + /* Check for problems contacting the licensing server */ if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) { - $this->log->error( 'Failed to contact remote API for license status check. Rescheduling.' ); - wp_schedule_single_event( strtotime( '+ 1 hour' ), 'gfpdf_' . $this->get_slug() . '_license_check' ); + $this->log->error( + 'Failed to contact remote API for license status check.', + [ + 'slug' => $this->get_slug(), + 'error' => is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_response_code( $response ), + ] + ); + + wp_schedule_single_event( strtotime( '+3 hour' ), 'gfpdf_' . $this->get_slug() . '_license_check' ); return false; } + /* Check for a malformed response */ $license_check = json_decode( wp_remote_retrieve_body( $response ) ); + if ( $license_check === null ) { + $this->log->error( + 'Invalid response returned from license status check.', + [ + 'slug' => $this->get_slug(), + 'response' => wp_remote_retrieve_body( $response ), + ] + ); - /* License still valid, no need to do anything */ - if ( isset( $license_check->license ) && $license_check->license === 'valid' ) { - $this->log->notice( 'License key still valid.' ); + wp_schedule_single_event( strtotime( '+3 hour' ), 'gfpdf_' . $this->get_slug() . '_license_check' ); return false; } - /* Error occurred. Update status and message in the license settings */ - $possible_responses = $this->data->addon_license_responses( $this->get_name() ); + if ( isset( $license_check->license ) && $license_check->license === 'valid' ) { + /* License is still valid, do nothing */ - /* Ensure we have a known error */ - if ( ! isset( $license_check->license ) || ! isset( $possible_responses[ $license_check->license ] ) ) { - $this->log->error( 'Unknown license status returned from remote API' ); + return true; + } - return false; + /* License status has changed. Update database */ + return $this->update_license_status_from_response( $this->get_license_key(), $response, true ); + } + + /** + * Parse and extract the addon license status from the API response + * + * @param string $license_key Current license key + * @param array|\WP_Error $response The raw response from wp_remote_*()) + * @param bool $use_database Whether to save the license info in the database + * + * @return bool + * + * @since 6.16.0 + */ + public function update_license_status_from_response( $license_key, $response, $use_database = false ) { + $response_code = wp_remote_retrieve_response_code( $response ); + if ( is_wp_error( $response ) || $response_code !== 200 ) { + $license_data = new \stdClass(); + + /* handle rate limiting */ + if ( $response_code === 429 ) { + $license_data->error = 'rate_limit'; + } + } else { + $license_data = json_decode( wp_remote_retrieve_body( $response ) ); + } + + $possible_responses = $this->data->addon_license_responses( $this->get_name() ); + + $status = 'error'; + if ( ! empty( $license_data->error ) ) { + $status = $license_data->error; + } elseif ( ! empty( $license_data->license ) ) { + $status = $license_data->license; } - $license_info['status'] = $license_check->license; - $license_info['message'] = $possible_responses[ $license_check->license ]; + /* Build the info fresh — all three fields are set here, so a get_license_info() read (and its per-call + "Get plugin license details" notice) would be redundant */ + $license_info = [ + 'license' => $license_key, + 'status' => $status, + 'message' => $possible_responses[ $status ] ?? $possible_responses['generic'], + ]; - switch ( $license_check->license ) { + switch ( $license_info['status'] ) { case 'expired': $date_format = get_option( 'date_format' ); - $dt = new \DateTimeImmutable( $license_check->expires, wp_timezone() ); - $date = $dt === false ? gmdate( $date_format, false ) : $dt->format( $date_format ); + $expires = $license_data->expires ?? ''; + + if ( empty( $expires ) ) { + /* DateTimeImmutable('') silently resolves to "now", implying the key expired today; surface unknown */ + $date = __( 'an unknown date', 'gravity-pdf' ); + } else { + try { + $dt = new \DateTimeImmutable( $expires, wp_timezone() ); + $date = $dt->format( $date_format ); + } catch ( \Exception $e ) { + /* gmdate() without a timestamp uses the current time, avoiding the 1 Jan 1970 gmdate(fmt, false) gives */ + $date = gmdate( $date_format ); + } + } $url = add_query_arg( [ @@ -711,7 +994,7 @@ public function schedule_license_check() { [ 'edd_action' => 'add_to_cart', 'download_id' => $this->get_edd_download_id(), - 'edd_options[price_id]' => $license_check->price_id, + 'edd_options[price_id]' => $license_data->price_id ?? '', ], 'https://gravitypdf.com/checkout/' ); @@ -724,8 +1007,8 @@ public function schedule_license_check() { [ 'view' => 'upgrades', 'action' => 'manage_licenses', - 'license_id' => $license_check->license_id, - 'payment_id' => $license_check->payment_id, + 'license_id' => $license_data->license_id ?? '', + 'payment_id' => $license_data->payment_id ?? '', ], 'https://gravitypdf.com/account/' ); @@ -734,10 +1017,12 @@ public function schedule_license_check() { break; } - $this->log->notice( 'License key no longer valid', $license_info ); - $this->update_license_info( $license_info ); + $this->log->notice( 'License key status', array_merge( $license_info, [ 'slug' => $this->get_slug() ] ) ); - return true; + $this->update_license_info( $license_info, $use_database ); + $this->flush_update_cache(); + + return in_array( $license_info['status'], [ 'active', 'valid' ], true ); } /** @@ -747,10 +1032,13 @@ public function schedule_license_check() { */ public function license_registration() { - $license_info = $this->get_license_info(); - $edd_id = $this->get_edd_download_id(); + /* On a network-activated Multisite, the primary site manages licensing */ + if ( \GPDFAPI::get_misc_class()->is_secondary_network_site( PDF_PLUGIN_BASENAME ) ) { + return; + } - if ( $license_info['status'] === 'active' || empty( $edd_id ) ) { + $edd_id = $this->get_edd_download_id(); + if ( in_array( $this->get_license_status(), [ 'active', 'valid' ], true ) || empty( $edd_id ) ) { return; } @@ -806,4 +1094,180 @@ public function plugin_row_meta( $links, $file ) { return (array) $links; } + + /** + * Do API call to GravityPDF.com to activate the current add-on license key + * + * @param string $license_key The current license key for this add-on + * @param bool $use_database Auto-update the database with the response + * + * @return array The API response and license status + * + * @since 6.16.0 + */ + public function activate_license( $license_key = '', $use_database = false ) { + + if ( empty( $license_key ) ) { + $license_key = $this->get_license_key(); + } + + $response = wp_remote_post( + $this->data->store_url, + [ + 'timeout' => 15, + 'body' => array_merge( + $this->get_default_api_params(), + [ + 'edd_action' => 'activate_license', + 'license' => $license_key, + ], + ), + ] + ); + + $this->update_license_status_from_response( $license_key, $response, $use_database ); + + do_action( 'gfpdf_addon_post_license_activation', $response, $this, $use_database ); + + return $this->get_license_info(); + } + + /** + * Listen for all license activations, and auto-activate addon if license supports it + * + * @param array $response + * @param Helper_Abstract_Addon $addon + * + * @return void + * + * @since 6.16.0 + */ + public function maybe_auto_activate_license( $response, $addon, $use_database = false ) { + /* The gfpdf_addon_post_license_activation action is public: a third-party do_action() may fire it with fewer + args (default $use_database) or a non-addon second arg — bail before dereferencing it */ + if ( ! $addon instanceof self ) { + return; + } + + /* skip if current addon doing licence activation */ + if ( $this->get_edd_download_id() === $addon->get_edd_download_id() ) { + return; + } + + /* skip if invalid response, or not an Access Pass license */ + $license_data = json_decode( wp_remote_retrieve_body( $response ) ); + if ( ! $license_data ) { + return; + } + + if ( ! isset( $license_data->products ) || ! is_array( $license_data->products ) ) { + return; + } + + /* skip if addon not available in Access Pass */ + if ( ! in_array( (int) $this->get_edd_download_id(), $license_data->products, true ) ) { + return; + } + + $this->update_license_info( $addon->get_license_info(), $use_database ); + + $this->license_auto_activated = true; + } + + /** + * Do API call to GravityPDF.com to deactivate add-on license + * + * @return bool + * + * @since 6.16.0 + */ + public function deactivate_license() { + $response = wp_remote_post( + $this->data->store_url, + [ + 'timeout' => 15, + 'body' => array_merge( + [ 'edd_action' => 'deactivate_license' ], + $this->get_default_api_params() + ), + ] + ); + + /* Remove license data from database, no matter if the API request fails */ + $this->delete_license_info(); + $this->flush_update_cache(); + + /* If API error exit early */ + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return false; + } + + /* Get API response and check license is now deactivated */ + $license_data = json_decode( wp_remote_retrieve_body( $response ) ); + if ( ! isset( $license_data->license ) || $license_data->license !== 'deactivated' ) { + return false; + } + + $this->log->notice( 'License successfully deactivated', [ 'slug' => $this->get_slug() ] ); + + do_action( 'gfpdf_addon_post_license_deactivation', $response, $this ); + + return true; + } + + /** + * Listen for all license deactivations, and auto-deactivate addon if license supports it + * + * @param array $response + * @param Helper_Abstract_Addon $addon + * + * @return void + * + * @since 6.16.0 + */ + public function maybe_auto_deactivate_license( $response, $addon ) { + /* Public action (see maybe_auto_activate_license): guard against a non-addon second arg from a third-party do_action() */ + if ( ! $addon instanceof self ) { + return; + } + + /* skip if current addon doing licence activation */ + if ( $this->get_edd_download_id() === $addon->get_edd_download_id() ) { + return; + } + + /* skip if invalid response, or not an Access Pass license */ + $license_data = json_decode( wp_remote_retrieve_body( $response ) ); + if ( ! $license_data ) { + return; + } + + if ( ! isset( $license_data->products ) || ! is_array( $license_data->products ) ) { + return; + } + + /* skip if addon not available in Access Pass */ + if ( ! in_array( (int) $this->get_edd_download_id(), $license_data->products, true ) ) { + return; + } + + $this->update_license_info( $addon->get_license_info(), true ); + + $this->license_auto_deactivated = true; + } + + /** + * Delete the add-on update information + * + * @since 6.16.0 + * @return void + */ + public function flush_update_cache() { + if ( ! $this->plugin_updater ) { + return; + } + + $this->plugin_updater->delete_version_info_cache(); + $this->plugin_updater->delete_transient_plugin_info(); + } } diff --git a/src/Helper/Helper_Abstract_Options.php b/src/Helper/Helper_Abstract_Options.php index c03a82d5d..9bb135f62 100644 --- a/src/Helper/Helper_Abstract_Options.php +++ b/src/Helper/Helper_Abstract_Options.php @@ -1715,9 +1715,17 @@ public function license_callback( $args ) { /* get selected value (if any) */ $value = $this->get_form_value( $args ); - $error_statuses = [ '', 'active' ]; - $is_error = ! in_array( $value['status'], $error_statuses, true ); - $is_active = $value['status'] === 'active'; + /** @var Helper_Abstract_Addon|null $addon */ + $addon = $args['data'] ?? null; + $hardcoded_license = $addon instanceof Helper_Abstract_Addon ? $addon->get_license_key_from_constant() : ''; + if ( $hardcoded_license ) { + $value['key'] = $hardcoded_license; + $args['desc2'] = __( 'License key set by the site administrator.', 'gravity-pdf' ); + $args['desc2'] .= ' ' . __( 'Learn more.', 'gravity-pdf' ) . ''; + } + + $is_error = ! in_array( $value['status'], [ '', 'active', 'valid' ], true ); + $is_active = in_array( $value['status'], [ 'active', 'valid' ], true ); ?> @@ -1742,9 +1750,10 @@ public function license_callback( $args ) { class="" name="gfpdf_settings[]" value="" + /> - + + ` + ) +} + +/* Trigger the click, then hand the wired callback whatever the endpoint (or jQuery's error handler) would return. */ +function deactivate (respondWith) { + $('.gfpdf-deactivate-license').first().trigger('click') + const callback = ajaxCall.mock.calls[0][1] + callback(respondWith) +} + +const fieldValue = (slug, suffix = '') => $(`#gfpdf_settings\\[license_${slug}${suffix}\\]`).val() + +describe('setupLicenseDeactivation', () => { + beforeEach(() => { + $('body').append(renderLicenseField('sample')) + setupLicenseDeactivation() + }) + + afterEach(() => { + $('body').empty() + }) + + describe('on success', () => { + it('clears the stored key/message/status, removes the button, and shows the success message', () => { + deactivate({ success: 'License key deactivated.', extra: [] }) + + expect(fieldValue('sample')).toBe('') + expect(fieldValue('sample', '_message')).toBe('') + expect(fieldValue('sample', '_status')).toBe('') + expect($('.gfpdf-deactivate-license').length).toBe(0) + + const $message = $('#gfpdf-settings-field-wrapper-license_sample #message') + expect($message.hasClass('success')).toBe(true) + expect($message.hasClass('error')).toBe(false) + expect($message.html()).toBe('License key deactivated.') + }) + + it('also tears down any All Access Pass siblings returned in extra', () => { + $('body').append(renderLicenseField('sibling')) + + deactivate({ success: 'Access Pass license key deactivated.', extra: ['sibling'] }) + + expect(fieldValue('sibling')).toBe('') + expect($('#gfpdf-settings-field-wrapper-license_sibling button').length).toBe(0) + }) + }) + + describe('on an application error (HTTP 200 { error })', () => { + it('keeps the field and button so the user can retry, and shows the error', () => { + deactivate({ error: 'An API error occurred.' }) + + expect(fieldValue('sample')).toBe('a-real-license-key') + expect($('.gfpdf-deactivate-license').length).toBe(1) + expect($('.gfpdf-deactivate-license').prop('disabled')).toBe(false) + + const $message = $('#gfpdf-settings-field-wrapper-license_sample #message') + expect($message.hasClass('error')).toBe(true) + expect($message.hasClass('success')).toBe(false) + expect($message.html()).toBe('An API error occurred.') + }) + }) + + describe('on a transport/auth failure (jqXHR, not our JSON)', () => { + it('does not clear the field or remove the button, and shows the generic fallback message', () => { + /* jQuery's error handler passes the raw jqXHR — neither success nor error is a string */ + deactivate({ readyState: 4, status: 401, statusText: 'Unauthorized' }) + + expect(fieldValue('sample')).toBe('a-real-license-key') + expect($('.gfpdf-deactivate-license').length).toBe(1) + expect($('.gfpdf-deactivate-license').prop('disabled')).toBe(false) + + const $message = $('#gfpdf-settings-field-wrapper-license_sample #message') + expect($message.hasClass('error')).toBe(true) + expect($message.hasClass('success')).toBe(false) + expect($message.html()).toBe(GFPDF.licenseDeactivationError) + }) + }) + + describe('on a repeat click while a request is in flight', () => { + it('ignores the second click so only one deactivation request is sent', () => { + $('.gfpdf-deactivate-license').first().trigger('click') + $('.gfpdf-deactivate-license').first().trigger('click') + + expect(ajaxCall).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/tests/js-unit/setupTests.js b/tests/js-unit/setupTests.js index 9384ed0aa..833035b34 100644 --- a/tests/js-unit/setupTests.js +++ b/tests/js-unit/setupTests.js @@ -17,6 +17,7 @@ window.GFPDF = { noResultText: 'It doesn\'t look like there are any topics related to your issue.', coreFontGithubError: 'Could not download Core Font list. Try again.', getSearchResultError: 'An error occurred. Please try again', + licenseDeactivationError: 'An error occurred and your license key may not have been deactivated. Reload the page and try again.', userCapabilities: { administrator: true }, // Font manager component fontListInstalledFonts: 'Installed Fonts', diff --git a/tests/phpunit/unit-tests/Controller/Test_Controller_Settings.php b/tests/phpunit/unit-tests/Controller/Test_Controller_Settings.php new file mode 100644 index 000000000..e58ba2649 --- /dev/null +++ b/tests/phpunit/unit-tests/Controller/Test_Controller_Settings.php @@ -0,0 +1,151 @@ +singleton->get_class( 'Model_Settings' ); + $view = $gfpdf->singleton->get_class( 'View_Settings' ); + + $this->controller = new Controller_Settings( $model, $view, $gfpdf->gform, $gfpdf->log, $gfpdf->notices, $gfpdf->data, $gfpdf->misc ); + } + + public function tear_down() { + parent::tear_down(); + $data = \GPDFAPI::get_data_class(); + $data->addon = []; + + /* Creating a subsite dirties process globals WP_UnitTestCase won't roll back; reset so later tests aren't polluted */ + global $wp_settings_errors, $wp_rewrite; + $wp_settings_errors = []; + $wp_rewrite->init(); + } + + public function test_bulk_license_check_schedule_with_no_addons() { + $this->controller->add_filters(); + do_action( 'init' ); + $this->assertFalse( wp_next_scheduled( 'gfpdf_bulk_license_check' ) ); + } + + public function test_bulk_license_check_schedule_with_addons() { + $addon = new ControllerSettingsAddon( + 'my-custom-plugin', + 'My Custom Plugin', + 'Gravity PDF', + '1.0', + '/path/to/plugin/file.php', + \GPDFAPI::get_data_class(), + \GPDFAPI::get_options_class(), + new Helper_Singleton(), + new Helper_Logger( 'my-custom-plugin', 'My Custom Plugin' ), + new Helper_Notices() + ); + + $addon->init(); + $this->controller->add_filters(); + do_action( 'init' ); + + $this->assertNotFalse( wp_next_scheduled( 'gfpdf_bulk_license_check' ) ); + } + + public function test_bulk_license_check_not_scheduled_on_secondary_network_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + $addon = new ControllerSettingsAddon( + 'my-custom-plugin', + 'My Custom Plugin', + 'Gravity PDF', + '1.0', + '/path/to/plugin/file.php', + \GPDFAPI::get_data_class(), + \GPDFAPI::get_options_class(), + new Helper_Singleton(), + new Helper_Logger( 'my-custom-plugin', 'My Custom Plugin' ), + new Helper_Notices() + ); + $addon->init(); + + /* Pose as a secondary site with Gravity PDF network-activated; scheduling reads the per-blog cron, so use a real blog */ + $network_plugins = static function () { return [ PDF_PLUGIN_BASENAME => time() ]; }; + add_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + switch_to_blog( $this->factory()->blog->create() ); + + $this->controller->add_filters(); + do_action( 'init' ); + + $this->assertFalse( wp_next_scheduled( 'gfpdf_bulk_license_check' ) ); + + restore_current_blog(); + remove_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + } + + /* + * maybe_schedule_network_update_check() runs on every request via after_setup_theme, including the frontend and + * WP-Cron, where wp-admin/includes/plugin.php (which defines is_plugin_active_for_network()) isn't loaded. That + * fatal can't be reproduced here because the PHPUnit bootstrap always loads the file, so guard the contract by + * scanning the source. php_strip_whitespace() drops comments so only real code is matched. + */ + public function test_network_update_check_avoids_admin_only_plugin_functions() { + $source = php_strip_whitespace( ( new \ReflectionClass( Controller_Settings::class ) )->getFileName() ); + $this->assertStringNotContainsString( 'is_plugin_active_for_network(', $source ); + } + + /* + * The updater fires gpdf_sl_plugin_updater_api_response with ($response, $api_data, $plugin_file). If the filter + * isn't registered with accepted_args of 3, $plugin_file arrives null, licensing_bulk_get_version_api_response() + * can't identify the initiating product, and that plugin's own update is silently lost while every response-shape + * test still passes. Pin the arg-count. + */ + public function test_bulk_get_version_response_filter_registered_with_plugin_file_arg() { + $this->controller->add_filters(); + + $hook = $GLOBALS['wp_filter']['gpdf_sl_plugin_updater_api_response'] ?? null; + $this->assertNotNull( $hook ); + + $registered = null; + foreach ( $hook->callbacks[10] as $callback ) { + if ( is_array( $callback['function'] ) && $callback['function'][1] === 'licensing_bulk_get_version_api_response' ) { + $registered = $callback; + break; + } + } + + $this->assertNotNull( $registered, 'The bulk get_version response filter was not registered' ); + $this->assertSame( 3, $registered['accepted_args'] ); + } +} + +class ControllerSettingsAddon extends Helper_Abstract_Addon { +} 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 new file mode 100644 index 000000000..e04e520e6 --- /dev/null +++ b/tests/phpunit/unit-tests/Helper/Licensing/Test_EDD_SL_Plugin_Updater.php @@ -0,0 +1,1068 @@ +class = new EDD_SL_Plugin_Updater( + 'http://store.com', + 'test-plugin/test-plugin.php', + [ + 'version' => '0.1', + 'license' => 'abc123', + 'item_name' => 'Test Plugin', + 'item_id' => 57, + 'author' => 'Gravity PDF', + 'beta' => false, + ] ); + + remove_all_filters( 'pre_set_site_transient_update_plugins' ); + remove_all_filters( 'plugins_api' ); + remove_all_filters( 'pre_http_request' ); + + $active_plugins = get_option( 'active_plugins', array() ); + $active_plugins[] = 'test-plugin/test-plugin.php'; + update_option( 'active_plugins', $active_plugins ); + } + + public function test_check_update_with_new_version() { + $this->class->init(); + + $api_response = function ( $pre, $parsed_args, $url ) { + /* API response */ + if ( $url === 'http://store.com/' ) { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'new_version' => '0.2', + 'stable_version' => '0.2', + 'name' => 'Test Plugin', + 'slug' => 'test-plugin', + 'package' => 'https://store.com/download/123', + 'sections' => [ + 'description' => 'Excerpt here', + 'changelog' => 'Changelog here', + ], + 'banners' => [ + 'high' => 'https://store.com/banner-large.png', + 'low' => 'https://store.com/banner-small.png', + ], + 'icons' => [ + '1x' => 'https://store.com/icon-1.png', + '2x' => 'https://store.com/icon-2.png', + ], + 'requires' => '6.4', + 'requires_php' => '7.3', + 'tested' => '10.1', + ] ), + ]; + } + + /* WP.org response */ + + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'plugins' => [], + 'translations' => [], + 'no_update' => [], + ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response, 10, 3 ); + + wp_update_plugins(); + + /* Verify expected result */ + $updates = get_site_transient( 'update_plugins' ); + + $this->assertSame( '0.1', $updates->checked['test-plugin/test-plugin.php'] ); + $this->assertArrayHasKey( 'test-plugin/test-plugin.php', $updates->response ); + $this->assertSame( '0.2', $updates->response['test-plugin/test-plugin.php']->new_version ); + $this->assertSame( '0.2', $updates->response['test-plugin/test-plugin.php']->stable_version ); + $this->assertSame( 'Test Plugin', $updates->response['test-plugin/test-plugin.php']->name ); + $this->assertSame( 'test-plugin', $updates->response['test-plugin/test-plugin.php']->slug ); + $this->assertSame( 'https://store.com/download/123', $updates->response['test-plugin/test-plugin.php']->package ); + $this->assertSame( 'https://store.com/banner-large.png', $updates->response['test-plugin/test-plugin.php']->banners['high'] ); + $this->assertSame( 'https://store.com/icon-1.png', $updates->response['test-plugin/test-plugin.php']->icons['1x'] ); + $this->assertSame( '6.4', $updates->response['test-plugin/test-plugin.php']->requires ); + $this->assertSame( '7.3', $updates->response['test-plugin/test-plugin.php']->requires_php ); + $this->assertSame( '10.1', $updates->response['test-plugin/test-plugin.php']->tested ); + $this->assertSame( 'Excerpt here', $updates->response['test-plugin/test-plugin.php']->sections['description'] ); + $this->assertSame( 'Changelog here', $updates->response['test-plugin/test-plugin.php']->sections['changelog'] ); + $this->assertSame( 'Excerpt here', $updates->response['test-plugin/test-plugin.php']->description[0] ); + $this->assertSame( 'Changelog here', $updates->response['test-plugin/test-plugin.php']->changelog[0] ); + + /* Verify cleanup */ + $this->class->delete_transient_plugin_info(); + + $updates = get_site_transient( 'update_plugins' ); + + $this->assertArrayNotHasKey( 'test-plugin/test-plugin.php', $updates->checked ); + $this->assertArrayNotHasKey( 'test-plugin/test-plugin.php', $updates->response ); + $this->assertArrayNotHasKey( 'test-plugin/test-plugin.php', $updates->no_update ); + + $this->assertNotEmpty( get_option( $this->class->get_cache_key() ) ); + $this->class->delete_version_info_cache(); + $this->assertEmpty( get_option( $this->class->get_cache_key() ) ); + } + + public function test_check_update_with_current_version() { + $this->class->init(); + + $api_response = function ( $pre, $parsed_args, $url ) { + /* API response */ + if ( $url === 'http://store.com/' ) { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'new_version' => '0.1', + 'stable_version' => '0.1', + 'name' => 'Test Plugin', + 'slug' => 'test-plugin', + 'sections' => [ + 'description' => 'Excerpt here', + 'changelog' => 'Changelog here', + ], + 'banners' => [ + 'high' => 'https://store.com/banner-large.png', + 'low' => 'https://store.com/banner-small.png', + ], + 'icons' => [ + '1x' => 'https://store.com/icon-1.png', + '2x' => 'https://store.com/icon-2.png', + ], + 'requires' => '6.4', + 'requires_php' => '7.3', + 'tested' => '10.1', + ] ), + ]; + } + + /* WP.org response */ + + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'plugins' => [], + 'translations' => [], + 'no_update' => [], + ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response, 10, 3 ); + + wp_update_plugins(); + + /* Verify expected result */ + $updates = get_site_transient( 'update_plugins' ); + + $this->assertSame( '0.1', $updates->checked['test-plugin/test-plugin.php'] ); + $this->assertArrayNotHasKey( 'test-plugin/test-plugin.php', $updates->response ); + $this->assertSame( '0.1', $updates->no_update['test-plugin/test-plugin.php']->new_version ); + $this->assertSame( '0.1', $updates->no_update['test-plugin/test-plugin.php']->stable_version ); + $this->assertSame( 'Test Plugin', $updates->no_update['test-plugin/test-plugin.php']->name ); + $this->assertSame( 'test-plugin', $updates->no_update['test-plugin/test-plugin.php']->slug ); + $this->assertSame( 'https://store.com/banner-large.png', $updates->no_update['test-plugin/test-plugin.php']->banners['high'] ); + $this->assertSame( 'https://store.com/icon-1.png', $updates->no_update['test-plugin/test-plugin.php']->icons['1x'] ); + $this->assertSame( '6.4', $updates->no_update['test-plugin/test-plugin.php']->requires ); + $this->assertSame( '7.3', $updates->no_update['test-plugin/test-plugin.php']->requires_php ); + $this->assertSame( '10.1', $updates->no_update['test-plugin/test-plugin.php']->tested ); + $this->assertSame( 'Excerpt here', $updates->no_update['test-plugin/test-plugin.php']->sections['description'] ); + $this->assertSame( 'Changelog here', $updates->no_update['test-plugin/test-plugin.php']->sections['changelog'] ); + $this->assertSame( 'Excerpt here', $updates->no_update['test-plugin/test-plugin.php']->description[0] ); + $this->assertSame( 'Changelog here', $updates->no_update['test-plugin/test-plugin.php']->changelog[0] ); + } + + public function test_check_update_with_api_failure() { + $this->class->init(); + + $api_response = function ( $pre, $parsed_args, $url ) { + /* API response */ + if ( $url === 'http://store.com/' ) { + return [ + 'response' => [ 'code' => 500 ], + ]; + } + + /* WP.org response */ + + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'plugins' => [], + 'translations' => [], + 'no_update' => [], + ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response, 10, 3 ); + + wp_update_plugins(); + + /* Verify expected result */ + $updates = get_site_transient( 'update_plugins' ); + + $this->assertSame( '0.1', $updates->checked['test-plugin/test-plugin.php'] ); + $this->assertArrayNotHasKey( 'test-plugin/test-plugin.php', $updates->response ); + $this->assertArrayNotHasKey( 'test-plugin/test-plugin.php', $updates->no_update ); + + $this->assertTrue( $this->class->request_recently_failed() ); + + /* test expired */ + update_option( 'gpdf_sl_failed_http_' . md5( 'http://store.com/' ), time() - 60 ); + + $this->assertFalse( $this->class->request_recently_failed() ); + } + + public function test_get_version_from_remote_backs_off_on_malformed_200() { + $this->class->init(); + + /* 200 status but an empty body standardizes to false; without a recorded failure it would re-POST every call */ + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => '', + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + $this->assertFalse( $this->class->request_recently_failed() ); + $this->assertFalse( $this->class->get_version_from_remote() ); + $this->assertTrue( $this->class->request_recently_failed() ); + } + + public function test_standardize_api_response_does_not_unserialize_sections() { + /* A serialized-object string in a JSON field is an object-injection payload; it must not be unserialized */ + $response = new \stdClass(); + $response->sections = 'O:8:"stdClass":1:{s:3:"foo";s:3:"bar";}'; + + $result = $this->class->standardize_api_response( $response ); + + $this->assertSame( [], $result->sections ); + } + + public function test_standardize_api_response_unserializes_serialized_array_sections() { + /* Regression: serialized-array sections were dropped by the object-injection hardening, blanking the changelog modal */ + $response = new \stdClass(); + $response->sections = serialize( [ 'description' => 'Excerpt here', 'changelog' => 'Changelog here' ] ); + $response->banners = serialize( [ 'high' => 'https://store.com/banner-large.png' ] ); + $response->icons = serialize( [ '1x' => 'https://store.com/icon-1.png' ] ); + + $result = $this->class->standardize_api_response( $response ); + + $this->assertSame( 'Excerpt here', $result->sections['description'] ); + $this->assertSame( 'Changelog here', $result->sections['changelog'] ); + $this->assertSame( 'https://store.com/banner-large.png', $result->banners['high'] ); + $this->assertSame( 'https://store.com/icon-1.png', $result->icons['1x'] ); + $this->assertSame( 'Excerpt here', $result->description[0] ); + $this->assertSame( 'Changelog here', $result->changelog[0] ); + } + + public function test_standardize_api_response_serialized_array_neutralizes_nested_objects() { + /* A serialized array that nests an object must still not instantiate the class — objects stay disallowed */ + $response = new \stdClass(); + $response->sections = 'a:1:{s:9:"changelog";O:8:"stdClass":1:{s:3:"foo";s:3:"bar";}}'; + + $result = $this->class->standardize_api_response( $response ); + + $this->assertArrayHasKey( 'changelog', $result->sections ); + $this->assertNotInstanceOf( \stdClass::class, $result->sections['changelog'] ); + $this->assertIsArray( $result->sections['changelog'] ); + } + + public function test_standardize_api_response_decodes_json_string_sections() { + /* The store may JSON-encode sections/banners/icons instead of serializing them; both must resolve to arrays */ + $response = new \stdClass(); + $response->sections = wp_json_encode( [ 'description' => 'Excerpt here', 'changelog' => 'Changelog here' ] ); + $response->banners = wp_json_encode( [ 'high' => 'https://store.com/banner-large.png' ] ); + $response->icons = wp_json_encode( [ '1x' => 'https://store.com/icon-1.png' ] ); + + $result = $this->class->standardize_api_response( $response ); + + $this->assertSame( 'Excerpt here', $result->sections['description'] ); + $this->assertSame( 'Changelog here', $result->sections['changelog'] ); + $this->assertSame( 'https://store.com/banner-large.png', $result->banners['high'] ); + $this->assertSame( 'https://store.com/icon-1.png', $result->icons['1x'] ); + $this->assertSame( 'Changelog here', $result->changelog[0] ); + } + + public function test_standardize_api_response_returns_false_for_non_object_payload() { + /* A non-empty, non-object payload passes empty() but the property writes below fatal on PHP 8; each must bail + to false. Reachable via a third-party gpdf_sl_plugin_updater_api_response filter or a malformed 200 body. */ + $this->assertFalse( $this->class->standardize_api_response( json_decode( '"a bare string"' ) ) ); + $this->assertFalse( $this->class->standardize_api_response( json_decode( '[1,2,3]' ) ) ); + $this->assertFalse( $this->class->standardize_api_response( 42 ) ); + $this->assertFalse( $this->class->standardize_api_response( true ) ); + } + + public function test_get_cached_version_info_returns_false_for_corrupted_scalar_option() { + /* A corrupted scalar-string option (e.g. a network option edited by a super-admin) would throw a TypeError on + the array access inside read_timed_cache() without the is_array() guard */ + update_option( $this->class->get_cache_key(), 'corrupted-not-an-array' ); + + $this->assertFalse( $this->class->get_cached_version_info() ); + } + + public function test_check_update_already_exists() { + $updates = new \stdClass(); + $updates->response = [ + 'test-plugin' => 'yes', + ]; + + $this->assertSame( $updates, $this->class->check_update( $updates ) ); + $this->assertEmpty( get_option( $this->class->get_cache_key() ) ); + } + + public function test_check_update_override() { + $updater = new EDD_SL_Plugin_Updater( + 'http://store.com', + 'test-plugin/test-plugin.php', + [ + 'version' => '0.1', + 'license' => 'abc123', + 'item_name' => 'Test Plugin', + 'item_id' => 57, + 'author' => 'Gravity PDF', + 'beta' => false, + 'wp_override' => true, + ] ); + + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'new_version' => '0.1', + 'stable_version' => '0.1', + 'name' => 'Test Plugin', + 'slug' => 'test-plugin', + 'sections' => [ + 'description' => 'Excerpt here', + 'changelog' => 'Changelog here', + ], + 'banners' => [ + 'high' => 'https://store.com/banner-large.png', + 'low' => 'https://store.com/banner-small.png', + ], + 'icons' => [ + '1x' => 'https://store.com/icon-1.png', + '2x' => 'https://store.com/icon-2.png', + ], + 'requires' => '6.4', + 'requires_php' => '7.3', + 'tested' => '10.1', + ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response, 10, 3 ); + + $updates = new \stdClass(); + $updates->response = [ + 'test-plugin/test-plugin.php' => 'yes', + ]; + + /* Verify expected result */ + $updates = $updater->check_update( $updates ); + + $this->assertSame( '0.1', $updates->checked['test-plugin/test-plugin.php'] ); + $this->assertArrayNotHasKey( 'test-plugin/test-plugin.php', $updates->response ); + $this->assertSame( '0.1', $updates->no_update['test-plugin/test-plugin.php']->new_version ); + $this->assertSame( '0.1', $updates->no_update['test-plugin/test-plugin.php']->stable_version ); + $this->assertSame( 'Test Plugin', $updates->no_update['test-plugin/test-plugin.php']->name ); + $this->assertSame( 'test-plugin', $updates->no_update['test-plugin/test-plugin.php']->slug ); + } + + public function test_get_version_info_with_matching_store_url() { + $updater = new EDD_SL_Plugin_Updater( home_url(), 'test-plugin/test-plugin.php' ); + + $this->assertFalse( $updater->get_version_info() ); + } + + public function test_get_version_info_skipped_on_secondary_network_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + $http_called = false; + $spy = function () use ( &$http_called ) { + $http_called = true; + return new \WP_Error( 'blocked', 'no HTTP in tests' ); + }; + add_filter( 'pre_http_request', $spy ); + + /* Pose as a secondary site with the add-on network-activated; only a network option is read, so no real blog is needed */ + $network_plugins = static function () { return [ 'test-plugin/test-plugin.php' => time() ]; }; + add_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + switch_to_blog( PHP_INT_MAX ); + + $this->assertFalse( $this->class->get_version_info() ); + $this->assertFalse( $http_called ); + + restore_current_blog(); + remove_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + remove_filter( 'pre_http_request', $spy ); + } + + public function test_get_tested_version() { + global $wp_version; + + $wp_version = '6.5.6'; + + $version_info = new \stdClass(); + $version_info->tested = '6.5'; + $this->assertSame( $wp_version, $this->class->get_tested_version( $version_info ) ); + + $version_info->tested = '6.5.2'; + $this->assertSame( $wp_version, $this->class->get_tested_version( $version_info ) ); + + $version_info->tested = '6.5.8'; + $this->assertSame( '6.5.8', $this->class->get_tested_version( $version_info ) ); + + $version_info->tested = '6.4'; + + $this->assertSame( '6.4', $this->class->get_tested_version( $version_info ) ); + } + + public function test_set_license_key() { + $params = $this->class->get_version_api_params(); + $this->assertSame( 'abc123', $params['license'] ); + + $this->class->set_license_key( 'zxy987' ); + + $params = $this->class->get_version_api_params(); + $this->assertSame( 'zxy987', $params['license'] ); + } + + public function test_get_plugin_file() { + $this->assertSame( 'test-plugin/test-plugin.php', $this->class->get_plugin_file() ); + } + + public function test_plugins_api_filter() { + $this->class->init(); + + $args = new \stdClass(); + $args->slug = 'test-plugin'; + + $results = apply_filters( 'plugins_api', 'input123', 'hot_tags', $args ); + $this->assertSame( 'input123', $results ); + + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'new_version' => '0.2', + 'stable_version' => '0.2', + 'name' => 'Test Plugin', + 'slug' => 'test-plugin', + 'package' => 'https://store.com/download/123', + 'sections' => [ + 'description' => 'Excerpt here', + 'changelog' => 'Changelog here', + ], + 'banners' => [ + 'high' => 'https://store.com/banner-large.png', + 'low' => 'https://store.com/banner-small.png', + ], + 'icons' => [ + '1x' => 'https://store.com/icon-1.png', + '2x' => 'https://store.com/icon-2.png', + ], + 'requires' => '6.4', + 'requires_php' => '7.3', + 'tested' => '10.1', + ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + $results = apply_filters( 'plugins_api', 'input123', 'plugin_information', $args ); + + $this->assertSame( '0.2', $results->version ); + $this->assertSame( 'test-plugin/test-plugin.php', $results->plugin ); + $this->assertSame( '0.2', $results->new_version ); + $this->assertSame( '0.2', $results->stable_version ); + $this->assertSame( 'Test Plugin', $results->name ); + $this->assertSame( 'test-plugin', $results->slug ); + $this->assertSame( 'https://store.com/banner-large.png', $results->banners['high'] ); + $this->assertSame( 'https://store.com/icon-1.png', $results->icons['1x'] ); + $this->assertSame( '6.4', $results->requires ); + $this->assertSame( '7.3', $results->requires_php ); + $this->assertSame( '10.1', $results->tested ); + $this->assertSame( 'Excerpt here', $results->sections['description'] ); + $this->assertSame( 'Changelog here', $results->sections['changelog'] ); + } + + public function test_plugins_api_filter_api_failed() { + $this->class->init(); + + $args = new \stdClass(); + $args->slug = 'test-plugin'; + + $results = apply_filters( 'plugins_api', 'input123', 'hot_tags', $args ); + $this->assertSame( 'input123', $results ); + + $api_response = function () { + return [ + 'response' => [ 'code' => 500 ], + 'body' => '', + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + $results = apply_filters( 'plugins_api', new \stdClass(), 'plugin_information', $args ); + + $this->assertCount( 1, (array) $results ); // "plugin" key + } + + public function test_plugins_api_filter_returns_false_when_api_unreachable() { + $this->class->init(); + + $args = new \stdClass(); + $args->slug = 'test-plugin'; + + $api_response = function () { + return [ + 'response' => [ 'code' => 500 ], + 'body' => '', + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + /* WP core passes $_data = false by default; a failed API leaves it false — assigning $_data->plugin on that bool fatals on PHP 8 */ + $results = apply_filters( 'plugins_api', false, 'plugin_information', $args ); + + $this->assertFalse( $results ); + } + + public function test_plugins_api_filter_with_cache() { + $this->class->init(); + + $args = new \stdClass(); + $args->slug = 'test-plugin'; + + $results = apply_filters( 'plugins_api', 'input123', 'hot_tags', $args ); + $this->assertSame( 'input123', $results ); + + $results = apply_filters( 'plugins_api', 'input123', 'plugin_information', new \stdClass() ); + $this->assertSame( 'input123', $results ); + + update_option( $this->class->get_cache_key(), [ + 'timeout' => time() + 60, + 'value' => json_encode( [ + 'new_version' => '0.1', + 'stable_version' => '0.1', + 'name' => 'Test Plugin', + 'slug' => 'test-plugin', + 'sections' => [ + 'description' => 'Excerpt here', + 'changelog' => 'Changelog here', + ], + 'banners' => [ + 'high' => 'https://store.com/banner-large.png', + 'low' => 'https://store.com/banner-small.png', + ], + 'icons' => [ + '1x' => 'https://store.com/icon-1.png', + '2x' => 'https://store.com/icon-2.png', + ], + 'requires' => '6.4', + 'requires_php' => '7.3', + 'tested' => '10.1', + ] ), + ] ); + + $results = apply_filters( 'plugins_api', 'input123', 'plugin_information', $args ); + + $this->assertSame( '0.1', $results->version ); + $this->assertSame( 'test-plugin/test-plugin.php', $results->plugin ); + $this->assertSame( '0.1', $results->new_version ); + $this->assertSame( '0.1', $results->stable_version ); + $this->assertSame( 'Test Plugin', $results->name ); + $this->assertSame( 'test-plugin', $results->slug ); + $this->assertSame( 'https://store.com/banner-large.png', $results->banners['high'] ); + $this->assertSame( 'https://store.com/icon-1.png', $results->icons['1x'] ); + $this->assertSame( '6.4', $results->requires ); + $this->assertSame( '7.3', $results->requires_php ); + $this->assertSame( '10.1', $results->tested ); + $this->assertSame( 'Excerpt here', $results->sections['description'] ); + $this->assertSame( 'Changelog here', $results->sections['changelog'] ); + } + + public function test_show_update_notification_non_multisite() { + if ( is_multisite() ) { + $this->markTestSkipped( + 'Not running multisite tests' + ); + } + + $this->class->init(); + + ob_start(); + do_action( 'after_plugin_row', 'test-plugin/test-plugin.php', [] ); + $this->assertEmpty( ob_get_clean() ); + } + + public function test_show_update_notification_no_privs() { + if ( ! is_multisite() ) { + $this->markTestSkipped( + 'Not running multisite tests' + ); + } + + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + $this->assertIsInt( $user_id ); + wp_set_current_user( $user_id ); + + $this->class->init(); + + ob_start(); + do_action( 'after_plugin_row', 'test-plugin/test-plugin.php', [ 'Name' => 'Test Plugin' ] ); + + $this->assertEmpty( ob_get_clean() ); + } + + public function test_show_update_notification_no_update() { + if ( ! is_multisite() ) { + $this->markTestSkipped( + 'Not running multisite tests' + ); + } + + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + $this->assertIsInt( $user_id ); + grant_super_admin( $user_id ); + wp_set_current_user( $user_id ); + + $this->class->init(); + + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'new_version' => '0.1', + 'stable_version' => '0.1', + 'name' => 'Test Plugin', + 'slug' => 'test-plugin', + 'package' => 'https://store.com/download/123', + 'sections' => [ + 'description' => 'Excerpt here', + 'changelog' => 'Changelog here', + ], + 'banners' => [ + 'high' => 'https://store.com/banner-large.png', + 'low' => 'https://store.com/banner-small.png', + ], + 'icons' => [ + '1x' => 'https://store.com/icon-1.png', + '2x' => 'https://store.com/icon-2.png', + ], + 'requires' => '6.4', + 'requires_php' => '7.3', + 'tested' => '10.1', + ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + ob_start(); + do_action( 'after_plugin_row', 'test-plugin/test-plugin.php', [] ); + $this->assertEmpty( ob_get_clean() ); + } + + public function test_show_update_notification_with_update_no_privs() { + if ( ! is_multisite() ) { + $this->markTestSkipped( + 'Not running multisite tests' + ); + } + + update_site_option( 'menu_items', [ 'plugins' => true ] ); + + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + $this->assertIsInt( $user_id ); + wp_set_current_user( $user_id ); + + $this->class->init(); + + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'new_version' => '0.2', + 'stable_version' => '0.2', + 'name' => 'Test Plugin', + 'slug' => 'test-plugin', + 'package' => 'https://store.com/download/123', + 'sections' => [ + 'description' => 'Excerpt here', + 'changelog' => 'Changelog here', + ], + 'banners' => [ + 'high' => 'https://store.com/banner-large.png', + 'low' => 'https://store.com/banner-small.png', + ], + 'icons' => [ + '1x' => 'https://store.com/icon-1.png', + '2x' => 'https://store.com/icon-2.png', + ], + 'requires' => '6.4', + 'requires_php' => '7.3', + 'tested' => '10.1', + ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + ob_start(); + do_action( 'after_plugin_row', 'test-plugin/test-plugin.php', [ 'Name' => 'Test Plugin' ] ); + + $output = ob_get_clean(); + $this->assertStringContainsString( 'There is a new version of Test Plugin available.', $output ); + $this->assertStringContainsString( 'Contact your network administrator to install the update.', $output ); + } + + public function test_show_update_notification_with_update() { + if ( ! is_multisite() ) { + $this->markTestSkipped( + 'Not running multisite tests' + ); + } + + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + $this->assertIsInt( $user_id ); + grant_super_admin( $user_id ); + wp_set_current_user( $user_id ); + + $this->class->init(); + + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'new_version' => '0.2', + 'stable_version' => '0.2', + 'name' => 'Test Plugin', + 'slug' => 'test-plugin', + 'package' => 'https://store.com/download/123', + 'sections' => [ + 'description' => 'Excerpt here', + 'changelog' => 'Changelog here', + ], + 'banners' => [ + 'high' => 'https://store.com/banner-large.png', + 'low' => 'https://store.com/banner-small.png', + ], + 'icons' => [ + '1x' => 'https://store.com/icon-1.png', + '2x' => 'https://store.com/icon-2.png', + ], + 'requires' => '6.4', + 'requires_php' => '7.3', + 'tested' => '10.1', + ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + ob_start(); + do_action( 'after_plugin_row', 'test-plugin/test-plugin.php', [ 'Name' => 'Test Plugin' ] ); + + $output = ob_get_clean(); + $this->assertStringContainsString( 'There is a new version of Test Plugin available.', $output ); + $this->assertStringContainsString( 'View version 0.2 details', $output ); + $this->assertStringContainsString( 'update now', $output ); + } + + public function test_show_update_notification_with_update_no_changelog() { + if ( ! is_multisite() ) { + $this->markTestSkipped( + 'Not running multisite tests' + ); + } + + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + $this->assertIsInt( $user_id ); + grant_super_admin( $user_id ); + wp_set_current_user( $user_id ); + + $this->class->init(); + + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'new_version' => '0.2', + 'stable_version' => '0.2', + 'name' => 'Test Plugin', + 'slug' => 'test-plugin', + 'package' => 'https://store.com/download/123', + 'sections' => [ + 'description' => 'Excerpt here', + ], + 'banners' => [ + 'high' => 'https://store.com/banner-large.png', + 'low' => 'https://store.com/banner-small.png', + ], + 'icons' => [ + '1x' => 'https://store.com/icon-1.png', + '2x' => 'https://store.com/icon-2.png', + ], + 'requires' => '6.4', + 'requires_php' => '7.3', + 'tested' => '10.1', + ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + ob_start(); + do_action( 'after_plugin_row', 'test-plugin/test-plugin.php', [ 'Name' => 'Test Plugin' ] ); + + $output = ob_get_clean(); + $this->assertStringContainsString( 'There is a new version of Test Plugin available.', $output ); + $this->assertStringNotContainsString( 'View version 0.2 details', $output ); + $this->assertStringContainsString( 'Update now.', $output ); + } + + public function test_show_update_notification_with_update_with_changelog_no_package() { + if ( ! is_multisite() ) { + $this->markTestSkipped( + 'Not running multisite tests' + ); + } + + $user_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + $this->assertIsInt( $user_id ); + grant_super_admin( $user_id ); + wp_set_current_user( $user_id ); + + $this->class->init(); + + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + 'new_version' => '0.2', + 'stable_version' => '0.2', + 'name' => 'Test Plugin', + 'slug' => 'test-plugin', + 'sections' => [ + 'description' => 'Excerpt here', + 'changelog' => 'Changelog here', + ], + 'banners' => [ + 'high' => 'https://store.com/banner-large.png', + 'low' => 'https://store.com/banner-small.png', + ], + 'icons' => [ + '1x' => 'https://store.com/icon-1.png', + '2x' => 'https://store.com/icon-2.png', + ], + 'requires' => '6.4', + 'requires_php' => '7.3', + 'tested' => '10.1', + ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + ob_start(); + do_action( 'after_plugin_row', 'test-plugin/test-plugin.php', [ 'Name' => 'Test Plugin' ] ); + + $output = ob_get_clean(); + $this->assertStringContainsString( 'There is a new version of Test Plugin available.', $output ); + $this->assertStringContainsString( 'View version 0.2 details', $output ); + $this->assertStringNotContainsString( 'update now', $output ); + $this->assertStringNotContainsString( 'Update now.', $output ); + } + + public function test_is_non_active_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Not running multisite tests' ); + } + + $plugin = 'test-plugin/test-plugin.php'; + $active_plugins = []; + $network_plugins = []; + + /* Fake the per-site and network plugin lists without touching the shared options */ + $site_filter = static function () use ( &$active_plugins ) { + return $active_plugins; + }; + $network_filter = static function () use ( &$network_plugins ) { + return $network_plugins; + }; + add_filter( 'pre_option_active_plugins', $site_filter ); + add_filter( 'pre_site_option_active_sitewide_plugins', $network_filter ); + + $method = new \ReflectionMethod( $this->class, 'is_non_active_multisite' ); + $method->setAccessible( true ); + + /* Active on the current site */ + $active_plugins = [ $plugin ]; + $this->assertFalse( $method->invoke( $this->class ) ); + + /* Network activated */ + $active_plugins = []; + $network_plugins = [ $plugin => time() ]; + $this->assertFalse( $method->invoke( $this->class ) ); + + /* Not active anywhere -> non-active multisite */ + $active_plugins = []; + $network_plugins = []; + $this->assertTrue( $method->invoke( $this->class ) ); + + remove_filter( 'pre_option_active_plugins', $site_filter ); + remove_filter( 'pre_site_option_active_sitewide_plugins', $network_filter ); + } + + /* + * The update check runs under WP-Cron and on the frontend, where wp-admin/includes/plugin.php (which defines + * is_plugin_active()) isn't loaded. That fatal can't be reproduced here because the PHPUnit bootstrap always loads + * the file, so guard the contract by scanning the source. php_strip_whitespace() drops comments so only real code + * is matched. + */ + public function test_multisite_check_avoids_admin_only_plugin_functions() { + $source = php_strip_whitespace( ( new \ReflectionClass( EDD_SL_Plugin_Updater::class ) )->getFileName() ); + $this->assertStringNotContainsString( 'is_plugin_active(', $source ); + } + + public function test_set_version_info_cache_promotes_active_licensed_package_to_network() { + 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->assertNotEmpty( $cache ); + $this->assertSame( 'https://store.com/download/licensed-123', json_decode( $cache['value'] )->package ); + } + + public function test_set_version_info_cache_skips_promotion_when_license_inactive() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + delete_site_option( $this->class->get_network_cache_key() ); + $this->class->set_license_status( 'expired' ); + + /* The store can return a package even for an inactive license; that URL errors, so it must not be shared */ + $response = new \stdClass(); + $response->new_version = '0.2'; + $response->package = 'https://store.com/download/inactive-123'; + $this->class->set_version_info_cache( $response ); + + $this->assertFalse( get_site_option( $this->class->get_network_cache_key() ) ); + } + + public function test_get_repo_api_data_borrows_network_package_when_site_unlicensed() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + /* A licensed site elsewhere on the network has shared its package */ + $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 ) ] + ); + + /* This site sees the update but has no package of its own (missing/invalid license) */ + $local = new \stdClass(); + $local->new_version = '0.2'; + $local->package = ''; + $this->class->set_version_info_cache( $local ); + + $result = $this->class->get_repo_api_data(); + + $this->assertSame( '0.2', $result->new_version ); + $this->assertSame( 'https://store.com/download/licensed-123', $result->package ); + } + + public function test_set_version_info_cache_network_ttl_outlives_per_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + delete_option( $this->class->get_cache_key() ); + 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 ); + + $per_site = get_option( $this->class->get_cache_key() ); + $network = get_site_option( $this->class->get_network_cache_key() ); + + /* The shared package must survive quiet stretches between licensed-site checks, so it outlives the per-site cache */ + $this->assertGreaterThan( $per_site['timeout'], $network['timeout'] ); + } + + public function test_get_repo_api_data_does_not_borrow_expired_network_package() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + /* A licensed site shared a package, but the network cache has since expired */ + $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' => time() - 60, 'value' => wp_json_encode( $network ) ] + ); + + /* This site sees the update but has no package of its own (missing/invalid license) */ + $local = new \stdClass(); + $local->new_version = '0.2'; + $local->package = ''; + $this->class->set_version_info_cache( $local ); + + $result = $this->class->get_repo_api_data(); + + $this->assertSame( '0.2', $result->new_version ); + $this->assertEmpty( $result->package ); + } +} diff --git a/tests/phpunit/unit-tests/Helper/Log/Test_Redact_Processor.php b/tests/phpunit/unit-tests/Helper/Log/Test_Redact_Processor.php new file mode 100644 index 000000000..73bcaa9ba --- /dev/null +++ b/tests/phpunit/unit-tests/Helper/Log/Test_Redact_Processor.php @@ -0,0 +1,247 @@ +processor()->context( + [ + 'license' => 'abc123', + 'Authorization' => 'Bearer xyz', + 'status' => 'valid', + ] + ); + + $this->assertSame( '[redacted]', $context['license'] ); + $this->assertSame( '[redacted]', $context['Authorization'] ); + $this->assertSame( 'valid', $context['status'] ); + } + + /** + * Recurses into nested arrays so deeply-nested secrets (eg. HTTP response headers) are caught. + */ + public function test_redacts_nested_arrays() { + $context = $this->processor()->context( + [ + 'response' => [ + 'headers' => [ + 'authorization' => 'Bearer xyz', + 'content-type' => 'application/json', + ], + ], + ] + ); + + $this->assertSame( '[redacted]', $context['response']['headers']['authorization'] ); + $this->assertSame( 'application/json', $context['response']['headers']['content-type'] ); + } + + /** + * Objects are cast to arrays and recursed so an object-valued context can't smuggle secrets past redaction. + */ + public function test_redacts_nested_objects() { + $obj = new \stdClass(); + $obj->token = 'secret-value'; + $obj->harmless = 'keep-me'; + + $context = $this->processor()->context( [ 'data' => $obj ] ); + + $this->assertSame( '[redacted]', $context['data']['token'] ); + $this->assertSame( 'keep-me', $context['data']['harmless'] ); + } + + /** + * A circular reference must not recurse forever — the depth cap replaces the value instead of exhausting the stack. + */ + public function test_caps_recursion_on_circular_references() { + $node = new \stdClass(); + $node->child = $node; + + /* Without the depth cap this overflows the stack before returning; reaching the assertion proves it terminates. */ + $result = $this->processor()->context( [ 'root' => $node ] ); + + /* Follow the self-referential chain; it must bottom out at the marker rather than loop. */ + $cursor = $result['root']; + while ( is_array( $cursor ) ) { + $cursor = $cursor['child']; + } + + $this->assertSame( '[redacted]', $cursor ); + } + + /** + * Non-string keys and scalar values that don't match a deny-key pass through untouched. + */ + public function test_preserves_untargeted_values() { + $context = $this->processor()->context( + [ + 0 => 'positional', + 'count' => 5, + 'enabled' => true, + 'nothing' => null, + ] + ); + + $this->assertSame( [ 0 => 'positional', 'count' => 5, 'enabled' => true, 'nothing' => null ], $context ); + } + + /** + * The filter may add deny-keys. + */ + public function test_filter_adds_keys() { + add_filter( + 'gfpdf_logging_redact_keys', + function ( $keys ) { + $keys[] = 'my_custom_secret'; + + return $keys; + } + ); + + $context = $this->processor()->context( [ 'my_custom_secret' => 'hunter2', 'license' => 'abc' ] ); + + $this->assertSame( '[redacted]', $context['my_custom_secret'] ); + $this->assertSame( '[redacted]', $context['license'] ); + } + + /** + * The filter may only add keys — returning an empty set can't weaken the defaults. + */ + public function test_filter_cannot_remove_defaults() { + add_filter( + 'gfpdf_logging_redact_keys', + function () { + return []; + } + ); + + $context = $this->processor()->context( [ 'license' => 'abc123' ] ); + + $this->assertSame( '[redacted]', $context['license'] ); + } + + /** + * A raw non-JSON API body stored under a benign key (eg. 'response'/'body') isn't caught by keyed redaction, so + * its string value is run through the same message() pattern scrub — masking the signed URLs and echoed keys the + * raw-body fallback path would otherwise leak. + */ + public function test_pattern_scrubs_context_string_leaves() { + $context = $this->processor()->context( + [ + 'response' => 'package https://store.com/download/file.zip?signature=deadbeef', + 'body' => 'license 098f6bcd4621d373cade4e832627b4f6 rejected', + ] + ); + + $this->assertSame( 'package https://store.com/download/file.zip?', $context['response'] ); + $this->assertSame( 'license [redacted] rejected', $context['body'] ); + } + + /** + * set-cookie is a deny-key: a Set-Cookie header can carry a session secret. + */ + public function test_redacts_set_cookie_key() { + $context = $this->processor()->context( [ 'set-cookie' => 'session=abc; HttpOnly' ] ); + + $this->assertSame( '[redacted]', $context['set-cookie'] ); + } + + /** + * @dataProvider provider_message_patterns + */ + public function test_redacts_message_patterns( $message, $expected ) { + $this->assertSame( $expected, $this->processor()->message( $message ) ); + } + + public function provider_message_patterns() { + return [ + 'hex license' => [ 'License 098f6bcd4621d373cade4e832627b4f6 rejected', 'License [redacted] rejected' ], + 'bearer token' => [ 'Auth failed: Bearer abc.def.ghi', 'Auth failed: [redacted]' ], + 'google oauth' => [ 'token ya29.a0AfB_xyz-123 expired', 'token [redacted] expired' ], + 'stripe key' => [ 'using sk_4eC39HqLyjWDarjtT1zdp7dc', 'using [redacted]' ], + 'plain text' => [ 'nothing sensitive here', 'nothing sensitive here' ], + ]; + } + + /** + * Signed-link secrets live in the query string, so keep the path and drop everything after the ?. + */ + public function test_blanks_url_query_strings() { + $this->assertSame( + 'Download failed https://example.com/file.zip?', + $this->processor()->message( 'Download failed https://example.com/file.zip?token=secret&exp=123' ) + ); + } + + /** + * A newline in message data must not be able to forge a second log line. + */ + public function test_collapses_line_breaks() { + $this->assertSame( + 'line one line two line three', + $this->processor()->message( "line one\r\nline two\nline three" ) + ); + } + + /** + * Non-printable control characters are stripped before redaction. + */ + public function test_strips_control_characters() { + $this->assertSame( 'clean', $this->processor()->message( "cl\x00ea\x07n" ) ); + } + + /** + * __invoke redacts both the message body and the context in a single pass. + */ + public function test_invoke_redacts_message_and_context() { + $record = [ + 'message' => 'License 098f6bcd4621d373cade4e832627b4f6 rejected', + 'context' => [ 'license' => 'abc123', 'status' => 'invalid' ], + 'level' => MonoLogger::toMonologLevel( MonoLogger::WARNING ), + 'channel' => 'test', + 'datetime' => new DateTimeImmutable( true ), + 'extra' => [], + ]; + + $processed = ( $this->processor() )( $record ); + + $this->assertSame( 'License [redacted] rejected', $processed['message'] ); + $this->assertSame( '[redacted]', $processed['context']['license'] ); + $this->assertSame( 'invalid', $processed['context']['status'] ); + } + + /** + * Works as a pushed Monolog processor end-to-end. + */ + public function test_works_as_monolog_processor() { + $handler = new TestHandler(); + $logger = new MonoLogger( 'Test', [ $handler ], [ $this->processor() ] ); + + $logger->info( 'Key 098f6bcd4621d373cade4e832627b4f6', [ 'token' => 'shhh' ] ); + + $this->assertTrue( + $handler->hasRecordThatPasses( + function ( $record ): bool { + return $record['message'] === 'Key [redacted]' && $record['context']['token'] === '[redacted]'; + }, + MonoLogger::INFO + ) + ); + } +} diff --git a/tests/phpunit/unit-tests/Model/Test_Model_Settings.php b/tests/phpunit/unit-tests/Model/Test_Model_Settings.php new file mode 100644 index 000000000..bc56c0007 --- /dev/null +++ b/tests/phpunit/unit-tests/Model/Test_Model_Settings.php @@ -0,0 +1,639 @@ +model = new Model_Settings( $gfpdf->gform, $gfpdf->log, $gfpdf->notices, $gfpdf->options, $gfpdf->data, $gfpdf->misc, $gfpdf->templates ); + + $this->addon = new ModelSettingsAddon( + 'my-custom-plugin', + 'My Custom Plugin', + 'Gravity PDF', + '1.0', + '/path/to/plugin/file.php', + \GPDFAPI::get_data_class(), + \GPDFAPI::get_options_class(), + new Helper_Singleton(), + new Helper_Logger( 'my-custom-plugin', 'My Custom Plugin' ), + new Helper_Notices() + ); + + $this->addon->set_edd_download_id( 5 ); + + $this->addon1 = new ModelSettingsAddon( + 'my-other-plugin', + 'Other Plugin', + 'Gravity PDF', + '1.2', + '/path/to/plugin/file.php', + \GPDFAPI::get_data_class(), + \GPDFAPI::get_options_class(), + new Helper_Singleton(), + new Helper_Logger( 'my-other-plugin', 'My Custom Plugin' ), + new Helper_Notices() + ); + + $this->addon1->set_edd_download_id( 10 ); + + $this->addon->init(); + $this->addon1->init(); + } + + public function tear_down() { + parent::tear_down(); + $data = \GPDFAPI::get_data_class(); + $data->addon = []; + + /* Creating a subsite dirties process globals WP_UnitTestCase won't roll back; reset so later tests aren't polluted */ + global $wp_settings_errors, $wp_rewrite; + $wp_settings_errors = []; + $wp_rewrite->init(); + } + + public function test_license_bulk_get_version_api_params_skipped() { + /* Check skipped when not initialized */ + $data = \GPDFAPI::get_data_class(); + $data->updater = null; + $data->addon = []; + $this->assertTrue( $this->model->licensing_bulk_get_version_api_params( true ) ); + } + + public function test_license_bulk_get_version_api_params_missing_updater() { + /* Non-canonical build (updater key never registered): bulk the add-ons only, without throwing from Helper_Data::__get() */ + do_action( 'init' ); + + $data = \GPDFAPI::get_data_class(); + unset( $data->updater ); + + $this->assertNotEmpty( $data->addon ); + + $params = $this->model->licensing_bulk_get_version_api_params( [] ); + $this->assertArrayHasKey( 'edd_action', $params ); + $this->assertCount( count( $data->addon ), $params['products'] ); + } + + public function test_license_bulk_get_version_api_params_skips_uninitialized_addon() { + $this->setExpectedIncorrectUsage( 'GFPDF\Helper\Helper_Abstract_Addon::get_plugin_updater' ); + + do_action( 'init' ); + + /* Register an add-on whose updater was never initialized — get_plugin_updater() returns null */ + $data = \GPDFAPI::get_data_class(); + $uninitialized = new ModelSettingsAddon( + 'uninitialized-plugin', + 'Uninitialized Plugin', + 'Gravity PDF', + '1.0', + '/path/to/plugin/file.php', + \GPDFAPI::get_data_class(), + \GPDFAPI::get_options_class(), + new Helper_Singleton(), + new Helper_Logger( 'uninitialized-plugin', 'Uninitialized Plugin' ), + new Helper_Notices() + ); + $data->add_addon( $uninitialized ); + + /* Core + the two initialized add-ons; the uninitialized one is skipped rather than fataling */ + $params = $this->model->licensing_bulk_get_version_api_params( [] ); + $this->assertCount( 3, $params['products'] ); + } + + public function test_license_bulk_get_version_api_params_core_plugin() { + $data = \GPDFAPI::get_data_class(); + $data->addon = []; + do_action( 'init' ); + + $params = $this->model->licensing_bulk_get_version_api_params( [] ); + $this->assertArrayHasKey( 'edd_action', $params ); + $this->assertArrayHasKey( 'products', $params ); + $this->assertCount( 1, $params['products'] ); + $this->assertArrayHasKey( 'license', $params['products'][0] ); + $this->assertArrayHasKey( 'item_id', $params['products'][0] ); + $this->assertArrayHasKey( 'url', $params['products'][0] ); + } + + public function test_licensing_bulk_get_version_api_response() { + do_action( 'init' ); + + $params = $this->model->licensing_bulk_get_version_api_params( [] ); + $this->assertArrayHasKey( 'edd_action', $params ); + $this->assertArrayHasKey( 'products', $params ); + $this->assertCount( 3, $params['products'] ); + $this->assertArrayHasKey( 'license', $params['products'][0] ); + $this->assertArrayHasKey( 'item_id', $params['products'][0] ); + $this->assertArrayHasKey( 'url', $params['products'][0] ); + $this->assertArrayHasKey( 'license', $params['products'][1] ); + $this->assertArrayHasKey( 'item_id', $params['products'][1] ); + $this->assertArrayHasKey( 'url', $params['products'][1] ); + $this->assertArrayHasKey( 'license', $params['products'][2] ); + $this->assertArrayHasKey( 'item_id', $params['products'][2] ); + $this->assertArrayHasKey( 'url', $params['products'][2] ); + } + + public function test_licensing_bulk_get_version_api_response_maps_by_folder_slug() { + $data = \GPDFAPI::get_data_class(); + $data->addon = []; + $data->updater = null; + + /* + * Registered slug deliberately differs from the plugin-folder basename (e.g. a user-renamed folder). The API + * echoes back the folder slug the updater sent, not the registered slug — the exact mismatch L8 dropped. + */ + $initiator = new ModelSettingsAddon( + 'registered-initiator', + 'Initiator', + 'Gravity PDF', + '1.0', + '/plugins/folder-initiator/main.php', + $data, + \GPDFAPI::get_options_class(), + new Helper_Singleton(), + new Helper_Logger( 'registered-initiator', 'Initiator' ), + new Helper_Notices() + ); + + $sibling = new ModelSettingsAddon( + 'registered-sibling', + 'Sibling', + 'Gravity PDF', + '1.0', + '/plugins/folder-sibling/main.php', + $data, + \GPDFAPI::get_options_class(), + new Helper_Singleton(), + new Helper_Logger( 'registered-sibling', 'Sibling' ), + new Helper_Notices() + ); + + $initiator->init(); + $sibling->init(); + do_action( 'init' ); + + $initiator_updater = $data->addon['registered-initiator']->get_plugin_updater(); + $sibling_updater = $data->addon['registered-sibling']->get_plugin_updater(); + + /* The sibling add-on is an active plugin in reality; mark it so the multisite cache gate doesn't skip its caching */ + update_option( 'active_plugins', [ plugin_basename( $sibling_updater->get_plugin_file() ) ] ); + + /* The bulk response is a list of product objects identified only by the folder slug echoed back */ + $response = [ + (object) [ 'slug' => 'folder-initiator', 'new_version' => '9.9.9', 'stable_version' => '9.9.9' ], + (object) [ 'slug' => 'folder-sibling', 'new_version' => '8.8.8', 'stable_version' => '8.8.8' ], + ]; + + /* Fire the response filter as the initiator's own updater would — its plugin file identifies the initiator */ + $initial = $this->model->licensing_bulk_get_version_api_response( $response, [], $initiator_updater->get_plugin_file() ); + + /* The initiator's own product is handed back for its normal caching path */ + $this->assertIsObject( $initial ); + $this->assertSame( 'folder-initiator', $initial->slug ); + $this->assertSame( '9.9.9', $initial->new_version ); + + /* The sibling was linked by its folder slug (not its registered slug) and cached — this is what L8 dropped */ + $cached = $sibling_updater->get_cached_version_info(); + $this->assertIsObject( $cached ); + $this->assertSame( '8.8.8', $cached->new_version ); + } + + public function test_licensing_bulk_license_check_success() { + do_action( 'init' ); + + $data = \GPDFAPI::get_data_class(); + foreach ( $data->addon as $addon ) { + $addon->update_license_info( [ 'license' => 'abc123', 'status' => 'valid' ] ); + } + + /* Do a good request */ + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + [ + 'item_id' => 5, + 'license' => 'invalid', + ], + + [ + 'item_id' => 10, + 'license' => 'valid', + ], + ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + $this->assertTrue( $this->model->licensing_bulk_license_check() ); + $this->assertSame( 'invalid', $this->addon->get_license_status() ); + $this->assertSame( 'valid', $this->addon1->get_license_status() ); + + remove_filter( 'pre_http_request', $api_response ); + } + + public function test_licensing_bulk_license_check_no_addons() { + $data = \GPDFAPI::get_data_class(); + $data->addon = []; + + $this->assertFalse( $this->model->licensing_bulk_license_check() ); + } + + public function test_licensing_bulk_license_check_bad_status_code() { + do_action( 'init' ); + + wp_clear_scheduled_hook( 'gfpdf_bulk_license_check' ); + $this->assertFalse( wp_next_scheduled( 'gfpdf_bulk_license_check' ) ); + + $data = \GPDFAPI::get_data_class(); + foreach ( $data->addon as $addon ) { + $addon->update_license_info( [ 'license' => 'abc123', 'status' => 'valid' ] ); + } + + /* Do a bad request */ + $api_response = function () { + return [ + 'response' => [ 'code' => 401 ], + 'body' => '', + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + $this->assertFalse( $this->model->licensing_bulk_license_check() ); + $this->assertNotFalse( wp_next_scheduled( 'gfpdf_bulk_license_check' ) ); + + remove_filter( 'pre_http_request', $api_response ); + } + + public function test_licensing_bulk_license_check_bad_response() { + do_action( 'init' ); + + wp_clear_scheduled_hook( 'gfpdf_bulk_license_check' ); + $this->assertFalse( wp_next_scheduled( 'gfpdf_bulk_license_check' ) ); + + $data = \GPDFAPI::get_data_class(); + foreach ( $data->addon as $addon ) { + $addon->update_license_info( [ 'license' => 'abc123', 'status' => 'valid' ] ); + } + + /* Do a malformed request */ + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => '', + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + $this->assertFalse( $this->model->licensing_bulk_license_check() ); + $this->assertNotFalse( wp_next_scheduled( 'gfpdf_bulk_license_check' ) ); + + remove_filter( 'pre_http_request', $api_response ); + } + + public function test_licensing_bulk_license_check_skipped_on_secondary_network_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + do_action( 'init' ); + + $data = \GPDFAPI::get_data_class(); + foreach ( $data->addon as $addon ) { + /* In-memory keys + updaters so the check would otherwise build params and POST — proving the gate is what stops it */ + $addon->update_license_info( [ 'license' => 'abc123', 'status' => 'valid' ] ); + } + + $http_called = false; + $spy = function () use ( &$http_called ) { + $http_called = true; + return new \WP_Error( 'blocked', 'no HTTP in tests' ); + }; + add_filter( 'pre_http_request', $spy ); + + /* Pose as a secondary site with Gravity PDF network-activated; only a network option is read, so no real blog is needed */ + $network_plugins = static function () { return [ PDF_PLUGIN_BASENAME => time() ]; }; + add_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + switch_to_blog( PHP_INT_MAX ); + + $this->assertFalse( $this->model->licensing_bulk_license_check() ); + $this->assertFalse( $http_called ); + + restore_current_blog(); + remove_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + remove_filter( 'pre_http_request', $spy ); + } + + public function test_schedule_network_update_check_uses_single_event_synced_to_wp_update_plugins() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + wp_clear_scheduled_hook( 'wp_update_plugins' ); + wp_clear_scheduled_hook( 'gfpdf_network_update_check' ); + + $wp_check = time() + 3 * HOUR_IN_SECONDS; + wp_schedule_event( $wp_check, 'twicedaily', 'wp_update_plugins' ); + + $this->model->schedule_network_update_check(); + + /* One minute after the primary site's plugin update check */ + $this->assertSame( $wp_check + 60, wp_next_scheduled( 'gfpdf_network_update_check' ) ); + + /* A one-off event (wp_get_schedule() is false for single events) so the offset is recomputed each run */ + $this->assertFalse( wp_get_schedule( 'gfpdf_network_update_check' ) ); + } + + public function test_schedule_network_update_check_falls_back_when_wp_update_plugins_unscheduled() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + wp_clear_scheduled_hook( 'wp_update_plugins' ); + wp_clear_scheduled_hook( 'gfpdf_network_update_check' ); + + $before = time(); + $this->model->schedule_network_update_check(); + + /* Falls back to ~12 hours out so the self-rescheduling chain survives a missing primary-site check */ + $this->assertGreaterThanOrEqual( $before + 12 * HOUR_IN_SECONDS, wp_next_scheduled( 'gfpdf_network_update_check' ) ); + $this->assertFalse( wp_get_schedule( 'gfpdf_network_update_check' ) ); + } + + public function test_schedule_network_update_check_floors_overdue_wp_update_plugins_to_future() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + wp_clear_scheduled_hook( 'wp_update_plugins' ); + wp_clear_scheduled_hook( 'gfpdf_network_update_check' ); + + /* wp_update_plugins only advances when the primary site runs cron, so it reports a PAST time when that site is + quiet. Left as-is the offset would be in the past and the self-rescheduling event would fire every cron spawn. */ + wp_schedule_event( time() - HOUR_IN_SECONDS, 'twicedaily', 'wp_update_plugins' ); + + $before = time(); + $this->model->schedule_network_update_check(); + + /* Floored to the future rather than immediately due */ + $this->assertGreaterThan( $before, wp_next_scheduled( 'gfpdf_network_update_check' ) ); + } + + public function test_run_network_update_check_reinjects_transient_in_subsite_context() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + /* A per-site-activated subsite (not network-activated) is the path this method targets */ + $blog_id = $this->factory()->blog->create(); + switch_to_blog( $blog_id ); + + wp_clear_scheduled_hook( 'gfpdf_network_update_check' ); + + /* Isolate the transient round-trip to our own spy: real listeners (Gravity Forms' check_update on the read + filter, stale add-on updaters) would otherwise run their own version checks and hit the network. We only + care which blog the re-injection fires in. */ + remove_all_filters( 'site_transient_update_plugins' ); + remove_all_filters( 'transient_update_plugins' ); + remove_all_filters( 'pre_set_site_transient_update_plugins' ); + + /* Seed a non-false update_plugins transient so the round-trip has something to re-inject */ + set_site_transient( 'update_plugins', (object) [ 'checked' => [] ] ); + + /* Record the blog context in which check_update() (pre_set_site_transient_update_plugins) fires */ + $fired_on_blog = null; + $spy = function ( $value ) use ( &$fired_on_blog ) { + $fired_on_blog = get_current_blog_id(); + return $value; + }; + add_filter( 'pre_set_site_transient_update_plugins', $spy ); + + $this->model->run_network_update_check(); + + remove_filter( 'pre_set_site_transient_update_plugins', $spy ); + + /* The re-injection must fire in the subsite context (where the per-site add-on's cache lives), not the main + site — switching to the main site would miss that cache and force an uncached API request per updater (H1) */ + $this->assertSame( $blog_id, $fired_on_blog ); + $this->assertNotSame( get_main_site_id(), $fired_on_blog ); + + /* And it re-arms the self-rescheduling event into the future (H2) */ + $this->assertGreaterThan( time(), wp_next_scheduled( 'gfpdf_network_update_check' ) ); + + restore_current_blog(); + } + + public function test_run_network_update_check_skips_on_main_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + wp_clear_scheduled_hook( 'gfpdf_network_update_check' ); + remove_all_filters( 'pre_set_site_transient_update_plugins' ); + set_site_transient( 'update_plugins', (object) [ 'checked' => [] ] ); + + $fired = false; + $spy = function ( $value ) use ( &$fired ) { + $fired = true; + return $value; + }; + add_filter( 'pre_set_site_transient_update_plugins', $spy ); + + /* The main site receives update checks through the normal flow, so the forced check is skipped */ + $this->model->run_network_update_check(); + + remove_filter( 'pre_set_site_transient_update_plugins', $spy ); + + $this->assertFalse( $fired ); + $this->assertFalse( wp_next_scheduled( 'gfpdf_network_update_check' ) ); + } + + public function test_run_network_update_check_skips_on_network_activated_secondary_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + wp_clear_scheduled_hook( 'gfpdf_network_update_check' ); + + /* Network-activated secondary sites are served by the normal flow, so the forced check must not re-arm here */ + $network_plugins = static function () { return [ PDF_PLUGIN_BASENAME => time() ]; }; + add_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + switch_to_blog( PHP_INT_MAX ); + + remove_all_filters( 'pre_set_site_transient_update_plugins' ); + set_site_transient( 'update_plugins', (object) [ 'checked' => [] ] ); + + $fired = false; + $spy = function ( $value ) use ( &$fired ) { + $fired = true; + return $value; + }; + add_filter( 'pre_set_site_transient_update_plugins', $spy ); + + $this->model->run_network_update_check(); + + remove_filter( 'pre_set_site_transient_update_plugins', $spy ); + + $this->assertFalse( $fired ); + $this->assertFalse( wp_next_scheduled( 'gfpdf_network_update_check' ) ); + + restore_current_blog(); + remove_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + } + + public function test_maybe_active_licenses_ignores_submitted_value_for_constant_managed_addon() { + $slug = $this->addon->get_slug(); + + /* A hardcoded constant key makes the add-on admin-managed: the submitted value must be ignored and the + authoritative constant value persisted, without burning an activation against the key */ + add_filter( 'gfpdf_addon_hardcoded_license_key', static function () { return 'CONSTANT-KEY'; } ); + $this->addon->update_license_info( [ 'license' => 'CONSTANT-KEY', 'status' => 'active', 'message' => 'ok' ] ); + + $http_called = false; + $spy = function () use ( &$http_called ) { + $http_called = true; + return new \WP_Error( 'blocked', 'no HTTP' ); + }; + add_filter( 'pre_http_request', $spy ); + + $input = $this->model->maybe_active_licenses( + [ + "license_$slug" => 'forged-attacker-key', + "license_{$slug}_status" => 'active', + "license_{$slug}_message" => 'forged', + ] + ); + + remove_filter( 'pre_http_request', $spy ); + remove_all_filters( 'gfpdf_addon_hardcoded_license_key' ); + + $this->assertFalse( $http_called ); + $this->assertSame( 'CONSTANT-KEY', $input[ "license_$slug" ] ); + $this->assertSame( 'active', $input[ "license_{$slug}_status" ] ); + } + + public function test_maybe_active_licenses_ignores_submitted_value_for_auto_activated_addon() { + $slug = $this->addon->get_slug(); + + /* Give the sibling a real Access Pass license, then auto-activate our add-on off it (its EDD id is in the pass) */ + $this->addon1->update_license_info( [ 'license' => 'AP-KEY', 'status' => 'active', 'message' => 'ok' ] ); + + $response = [ 'response' => [ 'code' => 200 ], 'body' => wp_json_encode( [ 'license' => 'valid', 'products' => [ 5 ] ] ) ]; + $this->addon->maybe_auto_activate_license( $response, $this->addon1, false ); + $this->assertTrue( $this->addon->is_license_admin_managed() ); + + $http_called = false; + $spy = function () use ( &$http_called ) { + $http_called = true; + return new \WP_Error( 'blocked', 'no HTTP' ); + }; + add_filter( 'pre_http_request', $spy ); + + $input = $this->model->maybe_active_licenses( + [ + "license_$slug" => 'forged-attacker-key', + "license_{$slug}_status" => 'active', + "license_{$slug}_message" => 'forged', + ] + ); + + remove_filter( 'pre_http_request', $spy ); + + /* The auto-activated (admin-managed) key is authoritative — no activation POST, forged value overwritten */ + $this->assertFalse( $http_called ); + $this->assertSame( 'AP-KEY', $input[ "license_$slug" ] ); + $this->assertSame( 'active', $input[ "license_{$slug}_status" ] ); + } + + public function test_maybe_active_licenses_clears_in_memory_status_on_empty_key() { + $slug = $this->addon->get_slug(); + + $this->addon->update_license_info( [ 'license' => 'old-key', 'status' => 'active', 'message' => 'ok' ] ); + + $this->model->maybe_active_licenses( + [ + "license_$slug" => ' ', + "license_{$slug}_status" => 'active', + ] + ); + + /* Clearing the field must sync the cached model, not just the persisted array (L5) */ + $this->assertSame( '', $this->addon->get_license_status() ); + $this->assertSame( '', $this->addon->get_license_key() ); + } + + public function test_licensing_bulk_license_check_skips_malformed_and_unknown_items() { + do_action( 'init' ); + + $data = \GPDFAPI::get_data_class(); + foreach ( $data->addon as $addon ) { + $addon->update_license_info( [ 'license' => 'abc123', 'status' => 'valid' ] ); + } + + /* item 5 = our add-on (valid → expired); the other two rows are malformed / unknown and must be skipped + without fataling, while the valid row still applies */ + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ + [ 'item_id' => 5, 'license' => 'expired' ], + [ 'license' => 'valid' ], // missing item_id + [ 'item_id' => 99999, 'license' => 'valid' ], // unknown add-on + ] ), + ]; + }; + add_filter( 'pre_http_request', $api_response ); + + $this->assertTrue( $this->model->licensing_bulk_license_check() ); + $this->assertSame( 'expired', $this->addon->get_license_status() ); + + remove_filter( 'pre_http_request', $api_response ); + } + +} + +class ModelSettingsAddon extends Helper_Abstract_Addon { +} diff --git a/tests/phpunit/unit-tests/test-addon.php b/tests/phpunit/unit-tests/test-addon.php index 528488f5c..f9530b064 100644 --- a/tests/phpunit/unit-tests/test-addon.php +++ b/tests/phpunit/unit-tests/test-addon.php @@ -87,6 +87,15 @@ public function set_up() { remove_all_actions( 'init' ); } + public function tear_down() { + parent::tear_down(); + + $data = \GPDFAPI::get_data_class(); + $data->addon = []; + + remove_all_filters( 'pre_http_request' ); + } + /** * @since 4.2 */ @@ -134,8 +143,6 @@ public function test_init() { $this->addon->init( [ $sub_addon ] ); $this->assertTrue( $sub_addon->run ); - $this->assertEquals( 10, has_action( 'init', [ $this->addon, 'plugin_updater' ] ) ); - $this->assertEquals( 10, has_action( 'admin_init', [ $this->addon, 'maybe_schedule_license_check' ] ) ); $this->assertEquals( 10, has_action( @@ -187,6 +194,49 @@ public function test_license_info() { $this->assertArrayNotHasKey( 'license_' . $this->addon->get_slug() . '_message', $settings ); } + public function test_get_license_key_from_constant() { + $this->assertFalse( $this->addon->get_license_key_from_constant() ); + $this->assertFalse( $this->addon2->get_license_key_from_constant() ); + + add_filter( 'gfpdf_addon_hardcoded_license_key', function ( $key ) { + return 'abc123'; + } ); + + $this->assertSame( 'abc123', $this->addon->get_license_key_from_constant() ); + $this->assertSame( 'abc123', $this->addon2->get_license_key_from_constant() ); + + remove_all_filters( 'gfpdf_addon_hardcoded_license_key' ); + + add_filter( 'gfpdf_addon_hardcoded_license_key', function ( $key ) { + return [ 'my-custom-plugin' => 'abc456', 'my-custom-plugin2' => 'xyz987' ]; + } ); + + $this->assertSame( 'abc456', $this->addon->get_license_key_from_constant() ); + $this->assertSame( 'xyz987', $this->addon2->get_license_key_from_constant() ); + } + + public function test_license_constant_overrides_database() { + $this->addon->update_license_info( + [ + 'license' => 'my key', + 'status' => 'active', + 'message' => 'Success!', + ] + ); + + $license = $this->addon->get_license_info(); + + $this->assertSame( 'my key', $license['license'] ); + + add_filter( 'gfpdf_addon_hardcoded_license_key', function ( $key ) { + return 'abc123'; + } ); + + $license = $this->addon->get_license_info(); + + $this->assertSame( 'abc123', $license['license'] ); + } + /* * @since 4.2 */ @@ -251,9 +301,10 @@ public function test_maybe_schedule_license_check() { * @since 4.2 */ public function test_schedule_license_check() { + /* Test a bad request */ $api_response = function() { return [ - 'response' => [ 'code' => 201 ], + 'response' => [ 'code' => 301 ], ]; }; @@ -262,7 +313,7 @@ public function test_schedule_license_check() { $this->addon->update_license_info( [ 'license' => '12345', 'status' => 'active', - 'message' => '', + 'message' => 'Your license key is valid!', ] ); $this->assertFalse( wp_next_scheduled( 'gfpdf_' . $this->addon->get_slug() . '_license_check' ) ); @@ -271,22 +322,70 @@ public function test_schedule_license_check() { remove_filter( 'pre_http_request', $api_response ); + /* Do a good request */ $api_response = function() { return [ 'response' => [ 'code' => 200 ], - 'body' => json_encode( [ 'license' => 'revoked', 'price_id' => 1 ] ), + 'body' => json_encode( [ 'license' => 'valid' ] ), ]; }; add_filter( 'pre_http_request', $api_response ); $this->assertTrue( $this->addon->schedule_license_check() ); + $this->assertSame( 'Your license key is valid!', $this->addon->get_license_message() ); + + remove_filter( 'pre_http_request', $api_response ); + + /* Test with a revoked license */ + $api_response = function() { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( [ 'license' => 'revoked', 'price_id' => 1 ] ), + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + $this->assertFalse( $this->addon->schedule_license_check() ); $this->assertStringContainsString( 'This license key has been cancelled', $this->addon->get_license_message() ); remove_filter( 'pre_http_request', $api_response ); $this->addon->delete_license_info(); } + /** + * @since 6.16.0 + */ + public function test_schedule_license_check_skipped_on_secondary_network_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + /* A valid key so the check would otherwise build params and POST — proving the gate is what stops it */ + $this->addon->update_license_info( [ 'license' => 'abc123', 'status' => 'valid' ] ); + + $http_called = false; + $spy = function () use ( &$http_called ) { + $http_called = true; + return new \WP_Error( 'blocked', 'no HTTP in tests' ); + }; + add_filter( 'pre_http_request', $spy ); + + /* Pose as a secondary site with Gravity PDF network-activated; only a network option is read, so no real blog is needed */ + $network_plugins = static function () { return [ PDF_PLUGIN_BASENAME => time() ]; }; + add_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + switch_to_blog( PHP_INT_MAX ); + + $this->assertFalse( $this->addon->schedule_license_check() ); + $this->assertFalse( $http_called ); + + restore_current_blog(); + remove_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + remove_filter( 'pre_http_request', $spy ); + $this->addon->delete_license_info(); + } + /** * @since 4.2 */ @@ -377,6 +476,267 @@ public function test_get_addon_setting_value() { $this->assertSame( 'Use Fallback', $this->addon2->get_addon_setting_value( 'addon_field', 'Use Fallback' ) ); $this->assertSame( 'Loading1', $this->addon2->get_addon_setting_value( 'string_loading_title' ) ); } + + public function test_central_plugin_updater() { + $this->setExpectedIncorrectUsage( 'GFPDF\Helper\Helper_Abstract_Addon::get_plugin_updater' ); + $this->assertNull( $this->addon->get_plugin_updater() ); + + $this->addon->init(); + do_action( 'init' ); + $this->assertNotNull( $this->addon->get_plugin_updater() ); + } + + public function test_auto_activate_license_constant() { + /* Set admin screen */ + set_current_screen( 'index.php' ); + + $this->assertEmpty( $this->addon->get_license_status() ); + + add_filter( 'gfpdf_addon_hardcoded_license_key', function ( $key ) { + return 'abc123'; + } ); + + $api_response = function () { + return [ + 'response' => [ 'code' => 200 ], + 'body' => json_encode( + [ + 'error' => 'missing', + ] + ), + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + $this->addon->init(); + do_action( 'init' ); + + $this->assertNotEmpty( $this->addon->get_license_key() ); + $this->assertNotEmpty( $this->addon->get_license_status() ); + } + + public function test_hardcoded_license_retries_after_failed_activation() { + set_current_screen( 'index.php' ); + + add_filter( 'gfpdf_addon_hardcoded_license_key', function () { + return 'abc123'; + } ); + + $http_calls = 0; + $ok = false; + $api = function () use ( &$http_calls, &$ok ) { + $http_calls++; + + return $ok + ? [ 'response' => [ 'code' => 200 ], 'body' => json_encode( [ 'license' => 'valid' ] ) ] + : new \WP_Error( 'down', 'API unreachable' ); + }; + + add_filter( 'pre_http_request', $api ); + + $this->addon->init(); + $backoff = 'gfpdf_license_activation_' . $this->addon->get_slug(); + + /* First attempt: the API is down, so activation fails and isn't stored as active */ + do_action( 'init' ); + $this->assertSame( 1, $http_calls ); + $this->assertNotContains( $this->addon->get_license_status(), [ 'active', 'valid' ] ); + + /* Backoff blocks an immediate retry so a bad/unreachable key doesn't POST on every request */ + do_action( 'init' ); + $this->assertSame( 1, $http_calls ); + + /* Once the backoff elapses the activation is retried — the old key-equality guard never retried */ + delete_transient( $backoff ); + $ok = true; + do_action( 'init' ); + $this->assertSame( 2, $http_calls ); + $this->assertSame( 'valid', $this->addon->get_license_status() ); + + remove_all_filters( 'gfpdf_addon_hardcoded_license_key' ); + } + + /** + * @since 6.16.0 + */ + public function test_hardcoded_license_activation_skipped_on_secondary_network_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + set_current_screen( 'index.php' ); + + add_filter( 'gfpdf_addon_hardcoded_license_key', static function () { + return 'abc123'; + } ); + + $http_called = false; + $spy = function () use ( &$http_called ) { + $http_called = true; + return new \WP_Error( 'blocked', 'no HTTP in tests' ); + }; + add_filter( 'pre_http_request', $spy ); + + /* Pose as a secondary site with Gravity PDF network-activated; the primary handles activation */ + $network_plugins = static function () { return [ PDF_PLUGIN_BASENAME => time() ]; }; + add_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + switch_to_blog( PHP_INT_MAX ); + + $this->addon->init(); + do_action( 'init' ); + + $this->assertFalse( $http_called ); + $this->assertEmpty( $this->addon->get_license_status() ); + + restore_current_blog(); + remove_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + remove_filter( 'pre_http_request', $spy ); + remove_all_filters( 'gfpdf_addon_hardcoded_license_key' ); + } + + /** + * @since 6.16.0 + */ + public function test_license_registration_notice_hidden_on_secondary_network_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + /* Preconditions that would otherwise render the notice: a known EDD ID and an unregistered license */ + $this->addon->set_edd_download_id( '123' ); + + /* Primary site shows the "Register your copy" prompt */ + ob_start(); + $this->addon->license_registration(); + $this->assertStringContainsString( 'Register your copy', ob_get_clean() ); + + /* Pose as a secondary site with Gravity PDF network-activated; the primary handles licensing */ + $network_plugins = static function () { return [ PDF_PLUGIN_BASENAME => time() ]; }; + add_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + switch_to_blog( PHP_INT_MAX ); + + ob_start(); + $this->addon->license_registration(); + $this->assertEmpty( ob_get_clean() ); + + restore_current_blog(); + remove_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + } + + /** + * An Access Pass activation on one add-on shares the license with every sibling whose EDD id is in the pass. + * + * @since 6.16.0 + */ + public function test_maybe_auto_activate_license_shares_across_access_pass() { + $this->addon->set_edd_download_id( 10 ); + $this->addon2->set_edd_download_id( 20 ); + + $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->assertFalse( $this->addon2->has_license_auto_activated() ); + $this->addon2->maybe_auto_activate_license( $response, $this->addon, false ); + + $this->assertTrue( $this->addon2->has_license_auto_activated() ); + $this->assertTrue( $this->addon2->is_license_admin_managed() ); + $this->assertSame( 'AP-KEY', $this->addon2->get_license_key() ); + $this->assertSame( 'active', $this->addon2->get_license_status() ); + + $this->addon->delete_license_info(); + $this->addon2->delete_license_info(); + } + + /** + * @since 6.16.0 + */ + public function test_maybe_auto_activate_license_skips_addon_not_in_access_pass() { + $this->addon->set_edd_download_id( 10 ); + $this->addon2->set_edd_download_id( 99 ); + + $this->addon->update_license_info( [ 'license' => 'AP-KEY', 'status' => 'active', 'message' => 'ok' ] ); + + /* addon2's EDD id (99) isn't among the pass's products, so it must not adopt the license */ + $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( $this->addon2->has_license_auto_activated() ); + $this->assertEmpty( $this->addon2->get_license_status() ); + + $this->addon->delete_license_info(); + } + + /** + * A plain (non-Access-Pass) activation response has no products array, so nothing is shared. + * + * @since 6.16.0 + */ + public function test_maybe_auto_activate_license_ignores_non_access_pass_response() { + $this->addon->set_edd_download_id( 10 ); + $this->addon2->set_edd_download_id( 20 ); + $this->addon->update_license_info( [ 'license' => 'KEY', 'status' => 'active', 'message' => 'ok' ] ); + + $response = [ 'response' => [ 'code' => 200 ], 'body' => wp_json_encode( [ 'license' => 'valid' ] ) ]; + $this->addon2->maybe_auto_activate_license( $response, $this->addon, false ); + + $this->assertFalse( $this->addon2->has_license_auto_activated() ); + + $this->addon->delete_license_info(); + } + + /** + * The gfpdf_addon_post_license_activation action is public: a third-party do_action() may pass a non-addon second + * arg (or omit the third), which must not fatal. + * + * @since 6.16.0 + */ + public function test_maybe_auto_activate_license_ignores_non_addon_arg() { + $this->addon->set_edd_download_id( 10 ); + + $response = [ 'response' => [ 'code' => 200 ], 'body' => wp_json_encode( [ 'license' => 'valid', 'products' => [ 10 ] ] ) ]; + $this->addon->maybe_auto_activate_license( $response, 'not-an-addon' ); + + $this->assertFalse( $this->addon->has_license_auto_activated() ); + } + + /** + * An Access Pass deactivation on one add-on cascades to every sibling in the pass. + * + * @since 6.16.0 + */ + public function test_maybe_auto_deactivate_license_shares_across_access_pass() { + $this->addon->set_edd_download_id( 10 ); + $this->addon2->set_edd_download_id( 20 ); + + $this->addon2->update_license_info( [ 'license' => 'AP-KEY', 'status' => 'active', 'message' => 'ok' ] ); + + /* The initiator has already wiped its own license before firing the action */ + $this->addon->delete_license_info(); + + $response = [ 'response' => [ 'code' => 200 ], 'body' => wp_json_encode( [ 'license' => 'deactivated', 'products' => [ 10, 20 ] ] ) ]; + + $this->assertFalse( $this->addon2->has_license_auto_deactivated() ); + $this->addon2->maybe_auto_deactivate_license( $response, $this->addon ); + + $this->assertTrue( $this->addon2->has_license_auto_deactivated() ); + $this->assertEmpty( $this->addon2->get_license_status() ); + + $this->addon2->delete_license_info(); + } + + /** + * @since 6.16.0 + */ + public function test_maybe_auto_deactivate_license_ignores_non_addon_arg() { + $this->addon->set_edd_download_id( 10 ); + + $response = [ 'response' => [ 'code' => 200 ], 'body' => wp_json_encode( [ 'license' => 'deactivated', 'products' => [ 10 ] ] ) ]; + $this->addon->maybe_auto_deactivate_license( $response, 'not-an-addon' ); + + $this->assertFalse( $this->addon->has_license_auto_deactivated() ); + } } /** diff --git a/tests/phpunit/unit-tests/test-ajax.php b/tests/phpunit/unit-tests/test-ajax.php index 082042b28..3265f1432 100644 --- a/tests/phpunit/unit-tests/test-ajax.php +++ b/tests/phpunit/unit-tests/test-ajax.php @@ -3,6 +3,10 @@ namespace GFPDF\Tests; use GFAPI; +use GFPDF\Helper\Helper_Abstract_Addon; +use GFPDF\Helper\Helper_Logger; +use GFPDF\Helper\Helper_Notices; +use GFPDF\Helper\Helper_Singleton; use WP_Ajax_UnitTestCase; use WPAjaxDieContinueException; use WPAjaxDieStopException; @@ -501,6 +505,95 @@ public function test_ajax_process_license_deactivation() { $this->assertStringContainsString( 'An unknown error occurred', json_decode( $this->_last_response )->error ); } + public function test_ajax_process_license_deactivation_shares_access_pass_siblings() { + global $gfpdf; + + $this->_setRole( 'administrator' ); + + $master = $this->register_deactivation_addon( 'master-plugin', 'Master', '/master/file.php', 5 ); + $sibling = $this->register_deactivation_addon( 'sibling-plugin', 'Sibling', '/sibling/file.php', 10 ); + $master->update_license_info( [ 'license' => 'AP-KEY', 'status' => 'active', 'message' => 'ok' ] ); + $sibling->update_license_info( [ 'license' => 'AP-KEY', 'status' => 'active', 'message' => 'ok' ] ); + + /* The API confirms deactivation and reports the pass products, so the sibling auto-deactivates too */ + $api = static function () { + return [ 'response' => [ 'code' => 200 ], 'body' => wp_json_encode( [ 'license' => 'deactivated', 'products' => [ 5, 10 ] ] ) ]; + }; + add_filter( 'pre_http_request', $api ); + + $_POST['nonce'] = wp_create_nonce( 'gfpdf_deactivate_license' ); + $_POST['addon_name'] = 'master-plugin'; + + try { + $this->_handleAjax( 'gfpdf_deactivate_license' ); + } catch ( WPAjaxDieContinueException $e ) { + /* do nothing (wp_die expected) */ + } + + $response = json_decode( $this->_last_response ); + + $this->assertTrue( isset( $response->success ), 'Expected a success response' ); + $this->assertContains( 'sibling-plugin', $response->extra ); + + remove_filter( 'pre_http_request', $api ); + remove_all_actions( 'gfpdf_addon_post_license_deactivation' ); + $gfpdf->data->addon = []; + } + + public function test_ajax_process_license_deactivation_api_failure() { + global $gfpdf; + + $this->_setRole( 'administrator' ); + + $master = $this->register_deactivation_addon( 'master-plugin', 'Master', '/master/file.php', 5 ); + $master->update_license_info( [ 'license' => 'KEY', 'status' => 'active', 'message' => 'ok' ] ); + + /* API unreachable → deactivate_license() returns false → the API-error branch responds */ + $api = static function () { + return new \WP_Error( 'down', 'API unreachable' ); + }; + add_filter( 'pre_http_request', $api ); + + $_POST['nonce'] = wp_create_nonce( 'gfpdf_deactivate_license' ); + $_POST['addon_name'] = 'master-plugin'; + + try { + $this->_handleAjax( 'gfpdf_deactivate_license' ); + } catch ( WPAjaxDieContinueException $e ) { + /* do nothing (wp_die expected) */ + } + + $this->assertStringContainsString( 'An API error occurred', json_decode( $this->_last_response )->error ); + + remove_filter( 'pre_http_request', $api ); + $gfpdf->data->addon = []; + } + + /** + * Build and register an add-on for the license-deactivation AJAX tests. + */ + private function register_deactivation_addon( $slug, $name, $file, $edd_id ) { + global $gfpdf; + + $addon = new AjaxDeactivationAddon( + $slug, + $name, + 'Gravity PDF', + '1.0', + $file, + $gfpdf->data, + $gfpdf->options, + new Helper_Singleton(), + new Helper_Logger( $slug, $name ), + new Helper_Notices() + ); + + $addon->set_edd_download_id( $edd_id ); + $addon->init(); + + return $addon; + } + public function test_ajax_save_core_font() { /* set up our post data and role */ $this->_setRole( 'administrator' ); @@ -550,3 +643,6 @@ public function test_ajax_save_core_font() { $this->assertTrue( json_decode( $this->_last_response ) ); } } + +class AjaxDeactivationAddon extends Helper_Abstract_Addon { +} diff --git a/tests/phpunit/unit-tests/test-form-settings.php b/tests/phpunit/unit-tests/test-form-settings.php index 625a082a5..f639ef899 100644 --- a/tests/phpunit/unit-tests/test-form-settings.php +++ b/tests/phpunit/unit-tests/test-form-settings.php @@ -297,6 +297,7 @@ public function test_maybe_save_pdf_settings() { public function test_process_list_view() { $GLOBALS['plugin_page'] = ''; $GLOBALS['hook_suffix'] = ''; + $GLOBALS['plugin_page'] = ''; require_once( GFCommon::get_base_path() . '/form_settings.php' ); @@ -336,6 +337,8 @@ public function test_show_edit_view() { $GLOBALS['plugin_page'] = ''; $GLOBALS['hook_suffix'] = ''; + $GLOBALS['plugin_page'] = ''; + require_once( GFCommon::get_base_path() . '/form_settings.php' ); $form_id = $this->form_id; diff --git a/tests/phpunit/unit-tests/test-helper-misc.php b/tests/phpunit/unit-tests/test-helper-misc.php index d8a92a790..ef849db94 100644 --- a/tests/phpunit/unit-tests/test-helper-misc.php +++ b/tests/phpunit/unit-tests/test-helper-misc.php @@ -778,4 +778,50 @@ public function test_rmdir() { $this->assertDirectoryExists( $path ); rmdir( $path ); } + + /** + * @since 6.16.0 + */ + public function test_is_secondary_network_site_single_site() { + if ( is_multisite() ) { + $this->markTestSkipped( 'Not running single site tests' ); + } + + $this->assertFalse( $this->misc->is_secondary_network_site( 'gravity-pdf/pdf.php' ) ); + } + + /** + * @since 6.16.0 + */ + public function test_is_secondary_network_site_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Not running multisite tests' ); + } + + $plugin = 'gravity-pdf/pdf.php'; + $network_plugins = [ $plugin => time() ]; + + /* Fake the network-activated plugin list without touching the shared option */ + $filter = static function () use ( &$network_plugins ) { + return $network_plugins; + }; + add_filter( 'pre_site_option_active_sitewide_plugins', $filter ); + + /* The primary site is never treated as secondary, even when the plugin is network activated */ + $this->assertFalse( $this->misc->is_secondary_network_site( $plugin ) ); + + /* Pose as a secondary site — we only read a network option, so no real blog is needed */ + switch_to_blog( PHP_INT_MAX ); + + /* Secondary site, plugin not network activated */ + $network_plugins = []; + $this->assertFalse( $this->misc->is_secondary_network_site( $plugin ) ); + + /* Secondary site, plugin network activated */ + $network_plugins = [ $plugin => time() ]; + $this->assertTrue( $this->misc->is_secondary_network_site( $plugin ) ); + + restore_current_blog(); + remove_filter( 'pre_site_option_active_sitewide_plugins', $filter ); + } } diff --git a/tests/phpunit/unit-tests/test-settings.php b/tests/phpunit/unit-tests/test-settings.php index 2859e87bd..19212591a 100644 --- a/tests/phpunit/unit-tests/test-settings.php +++ b/tests/phpunit/unit-tests/test-settings.php @@ -289,6 +289,37 @@ public function test_disable_tools_on_view_cap() { wp_set_current_user( 0 ); } + /** + * The License tab is removed on secondary network sites — licensing is managed by the primary site + * + * @since 6.16.0 + */ + public function test_license_tab_hidden_on_secondary_network_site() { + global $gfpdf; + + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite tests only' ); + } + + $this->add_addon_1(); + + /* Primary site: the License tab is present */ + $tabs = wp_list_pluck( $this->view->get_available_tabs(), 'id' ); + $this->assertContains( 'license', $tabs ); + + /* Pose as a secondary site with Gravity PDF network-activated */ + $network_plugins = static function () { return [ PDF_PLUGIN_BASENAME => time() ]; }; + add_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + switch_to_blog( PHP_INT_MAX ); + + $tabs = wp_list_pluck( $this->view->get_available_tabs(), 'id' ); + $this->assertNotContains( 'license', $tabs ); + + restore_current_blog(); + remove_filter( 'pre_site_option_active_sitewide_plugins', $network_plugins ); + $gfpdf->data->addon = []; + } + /** * Verify errors are highlighted appropriately * @@ -535,18 +566,28 @@ public function providerActivateLicense() { ], [ - 'An error occurred, please try again', + 'An unknown error occurred while checking the license.', [ 'error' => 'default', 'price_id' => 1 ], ], [ - 'An error occurred, please try again', + 'An unknown error occurred while checking the license.', [ 'error' => 'generic', 'price_id' => 1 ], ], + [ + 'An unknown error occurred while checking the license.', + [ 'error' => 'error', 'price_id' => 1 ], + ], + [ 'Your support license key has been activated for this domain', - [ 'success' => 'true' ], + [ 'license' => 'valid' ], + ], + + [ + 'Your support license key has been activated for this domain', + [ 'license' => 'active' ], ], ]; } @@ -592,6 +633,62 @@ public function provider_deactivate_license_key() { [ false, [ 'license' => 'deactivated' ], 500 ], ]; } + + public function test_api_status_error() { + global $gfpdf; + + $this->add_addon_1(); + + $api_response = function() { + return [ + 'response' => [ 'code' => 401 ], + 'body' => '' + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + $results = $this->model->maybe_active_licenses( + [ + 'license_my-custom-plugin' => 'user license key', + 'license_my-custom-plugin_message' => '', + 'license_my-custom-plugin_status' => '', + ] + ); + + $this->assertSame( 'An unknown error occurred while checking the license.', $results['license_my-custom-plugin_message'] ); + + remove_filter( 'pre_http_request', $api_response ); + $gfpdf->data->addon = []; + } + + public function test_api_body_error() { + global $gfpdf; + + $this->add_addon_1(); + + $api_response = function() { + return [ + 'response' => [ 'code' => 201 ], + 'body' => '' + ]; + }; + + add_filter( 'pre_http_request', $api_response ); + + $results = $this->model->maybe_active_licenses( + [ + 'license_my-custom-plugin' => 'user license key', + 'license_my-custom-plugin_message' => '', + 'license_my-custom-plugin_status' => '', + ] + ); + + $this->assertSame( 'An unknown error occurred while checking the license.', $results['license_my-custom-plugin_message'] ); + + remove_filter( 'pre_http_request', $api_response ); + $gfpdf->data->addon = []; + } } class Addon1 extends Helper_Abstract_Addon { diff --git a/tests/phpunit/unit-tests/test-uninstaller.php b/tests/phpunit/unit-tests/test-uninstaller.php index b276a293d..961339932 100644 --- a/tests/phpunit/unit-tests/test-uninstaller.php +++ b/tests/phpunit/unit-tests/test-uninstaller.php @@ -131,20 +131,50 @@ public function test_remove_plugin_options() { $installer->check_install_status(); update_option( 'gfpdf_settings', [] ); + update_option( 'gpdf_sl_abc_123', true ); + update_option( 'gpdf_sl_failed_123', true ); $this->assertNotFalse( get_option( 'gfpdf_is_installed' ) ); $this->assertNotFalse( get_option( 'gfpdf_current_version' ) ); $this->assertNotFalse( get_option( 'gfpdf_settings' ) ); + $this->assertNotFalse( get_option( 'gpdf_sl_abc_123' ) ); + $this->assertNotFalse( get_option( 'gpdf_sl_failed_123' ) ); $this->model->remove_plugin_options(); + /* flush the options cache so fresh values can be checked from the database */ + wp_cache_delete( 'alloptions', 'options' ); + $this->assertFalse( get_option( 'gfpdf_is_installed' ) ); $this->assertFalse( get_option( 'gfpdf_current_version' ) ); $this->assertFalse( get_option( 'gfpdf_settings' ) ); + $this->assertFalse( get_option( 'gpdf_sl_abc_123' ) ); + $this->assertFalse( get_option( 'gpdf_sl_failed_123' ) ); wp_set_current_user( 0 ); } + /** + * The network-shared license package cache lives in sitemeta, so it needs its own cleanup on uninstall + * + * @since 6.16.0 + */ + public function test_remove_plugin_network_options() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network options only exist on Multisite' ); + } + + update_site_option( 'gpdf_sl_net_abc123', [ 'timeout' => time(), 'value' => '{}' ] ); + $this->assertNotFalse( get_site_option( 'gpdf_sl_net_abc123' ) ); + + $this->model->remove_plugin_network_options(); + + /* The direct SQL delete bypasses the object cache */ + wp_cache_flush(); + + $this->assertFalse( get_site_option( 'gpdf_sl_net_abc123' ) ); + } + /** * Check we are successfully removing our GF PDF Settings *