From 69f0be6b2351e68b22ae264a21ce9cb913be9d74 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:30:19 -0400 Subject: [PATCH 1/5] feat(logging): check the WP Engine updater API for plugin updates Port FaustWP's plugin updater into WPGraphQL Logging as WPGraphQL\Logging\Updates\PluginUpdater and run it on admin_init with the wpgraphql-logging slug. Point the Update URI header at wpe-plugin-updates.wpengine.com so WordPress.org never offers updates for this plugin. --- .../src/Updates/PluginUpdater.php | 367 ++++++++++++++++++ .../wpgraphql-logging/wpgraphql-logging.php | 16 +- 2 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 plugins/wpgraphql-logging/src/Updates/PluginUpdater.php diff --git a/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php b/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php new file mode 100644 index 00000000..4a35cca7 --- /dev/null +++ b/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php @@ -0,0 +1,367 @@ +plugin_slug = $plugin_slug; + $this->plugin_basename = $plugin_basename; + } + + /** + * Create an updater for the given plugin and register its hooks. + * + * Intended to be called on `admin_init`. + * + * @param string $plugin_slug The slug registered with the WP Engine plugin updates API. + * @param string $plugin_basename The plugin basename as returned by plugin_basename(). + * + * @return self|null The updater, or null if the plugin could not be found. + */ + public static function check_for_updates( string $plugin_slug, string $plugin_basename ): ?self { + $updater = new self( $plugin_slug, $plugin_basename ); + + return $updater->register() ? $updater : null; + } + + /** + * Resolve the installed plugin and register the update filters. + * + * @return bool Whether the filters were registered. + */ + public function register(): bool { + if ( '' === $this->plugin_slug || '' === $this->plugin_basename ) { + return false; + } + + if ( ! $this->load_plugin_data() ) { + return false; + } + + add_filter( 'plugins_api', [ $this, 'filter_plugin_update_info' ], 20, 3 ); + add_filter( 'pre_set_site_transient_update_plugins', [ $this, 'filter_plugin_update_transient' ], 10, 1 ); + + return true; + } + + /** + * Add this plugin to the update_plugins transient when a newer version is available. + * + * @param mixed $transient The value of the `update_plugins` site transient. + * + * @return mixed The (possibly) modified transient. + */ + public function filter_plugin_update_transient( mixed $transient ): mixed { + if ( ! $transient instanceof stdClass ) { + return $transient; + } + + $result = $this->fetch_plugin_info(); + + if ( null === $result || ! version_compare( $this->plugin_version, $result->version, '<' ) ) { + return $transient; + } + + return $this->add_update_to_transient( $transient, $result ); + } + + /** + * Provide the plugin information shown in the "View details" modal. + * + * @param mixed $res The result object or array. Default false. + * @param string $action The type of information being requested from the Plugin Installation API. + * @param mixed $args Plugin API arguments. + * + * @return mixed The plugin information, or the unmodified result. + */ + public function filter_plugin_update_info( mixed $res, string $action, mixed $args ): mixed { + if ( 'plugin_information' !== $action ) { + return $res; + } + + if ( ! is_object( $args ) || ! isset( $args->slug ) || $this->plugin_dirname !== $args->slug ) { + return $res; + } + + $result = $this->fetch_plugin_info(); + + if ( null === $result ) { + return $res; + } + + return $this->parse_plugin_info( $result ); + } + + /** + * The URL of this plugin's manifest on the updates API. + */ + public function get_manifest_url(): string { + return trailingslashit( self::API_URL ) . trailingslashit( $this->plugin_slug ) . 'info.json'; + } + + /** + * Name of the option that caches the manifest body. + */ + public function get_cache_option_name(): string { + return self::CACHE_OPTION_PREFIX . sanitize_title( $this->plugin_dirname ); + } + + /** + * Name of the option that stores the cache expiry timestamp. + */ + public function get_cache_expiry_option_name(): string { + return $this->get_cache_option_name() . '-expiry'; + } + + /** + * Look up the installed plugin and store its directory name and version. + * + * @return bool Whether the plugin was found. + */ + protected function load_plugin_data(): bool { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $plugins = get_plugins(); + + if ( ! isset( $plugins[ $this->plugin_basename ] ) ) { + return false; + } + + $this->plugin_dirname = dirname( $this->plugin_basename ); + $this->plugin_version = (string) $plugins[ $this->plugin_basename ]['Version']; + + return true; + } + + /** + * Fetch the plugin manifest from the cache or the updates API. + * + * @return \stdClass|null The decoded manifest, or null if it is unavailable or invalid. + */ + protected function fetch_plugin_info(): ?stdClass { + $body = $this->get_cached_manifest(); + + if ( null === $body ) { + $body = $this->request_manifest(); + } + + if ( null === $body ) { + return null; + } + + $decoded = json_decode( $body ); + + if ( ! $decoded instanceof stdClass || ! $this->is_valid_manifest( $decoded ) ) { + return null; + } + + return $decoded; + } + + /** + * Return the cached manifest body if it has not expired. + */ + protected function get_cached_manifest(): ?string { + $expiry = (int) get_option( $this->get_cache_expiry_option_name(), 0 ); + $body = get_option( $this->get_cache_option_name() ); + + if ( time() > $expiry || ! is_string( $body ) || '' === $body ) { + return null; + } + + return $body; + } + + /** + * Request the manifest from the updates API and cache a successful response. + */ + protected function request_manifest(): ?string { + // Admin-only and cached for CACHE_TTL, so the 10s timeout used by FaustWP is acceptable here. + // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get + $response = wp_remote_get( + $this->get_manifest_url(), + [ + 'timeout' => 10, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout + 'headers' => [ + 'Accept' => 'application/json', + ], + ] + ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return null; + } + + $body = wp_remote_retrieve_body( $response ); + + if ( '' === $body ) { + return null; + } + + update_option( $this->get_cache_expiry_option_name(), time() + self::CACHE_TTL, false ); + update_option( $this->get_cache_option_name(), $body, false ); + + return $body; + } + + /** + * Check that the manifest has the fields needed to offer an update. + * + * @param \stdClass $manifest The decoded manifest. + */ + protected function is_valid_manifest( stdClass $manifest ): bool { + foreach ( [ 'name', 'slug', 'version', 'download_link' ] as $field ) { + if ( ! isset( $manifest->$field ) || ! is_string( $manifest->$field ) || '' === $manifest->$field ) { + return false; + } + } + + return true; + } + + /** + * Add the update offer for this plugin to the update_plugins transient. + * + * @param \stdClass $transient The `update_plugins` site transient. + * @param \stdClass $manifest The decoded manifest. + */ + protected function add_update_to_transient( stdClass $transient, stdClass $manifest ): stdClass { + $response = isset( $transient->response ) && is_array( $transient->response ) ? $transient->response : []; + $checked = isset( $transient->checked ) && is_array( $transient->checked ) ? $transient->checked : []; + + $response[ $this->plugin_basename ] = $this->parse_plugin_info( $manifest ); + $checked[ $this->plugin_basename ] = $manifest->version; + + $transient->response = $response; + $transient->checked = $checked; + + return $transient; + } + + /** + * Map the manifest to the object WordPress expects for updates and plugin information. + * + * @param \stdClass $manifest The decoded manifest. + */ + protected function parse_plugin_info( stdClass $manifest ): stdClass { + $res = new stdClass(); + $res->name = $manifest->name; + $res->slug = $manifest->slug; + $res->version = $manifest->version; + $res->requires = $manifest->requires ?? ''; + $res->requires_php = $manifest->requires_php ?? ''; + $res->download_link = $manifest->download_link; + $res->trunk = $manifest->download_link; + $res->new_version = $manifest->version; + $res->plugin = $this->plugin_basename; + $res->package = $manifest->download_link; + $res->tested = $this->get_tested_version( (string) ( $manifest->tested ?? '' ) ); + $res->sections = [ + 'description' => (string) ( $manifest->sections->description ?? '' ), + 'changelog' => (string) ( $manifest->sections->changelog ?? '' ), + ]; + + return $res; + } + + /** + * Work out the "tested up to" version to report. + * + * The plugin information modal and core update table compare versions + * strictly. If the site runs a newer major version than the plugin was + * tested with, report the plugin's tested version; otherwise report the + * site's exact version to avoid a false "untested" warning. + * + * @param string $tested The "tested up to" version from the manifest. + */ + protected function get_tested_version( string $tested ): string { + global $wp_version; + + $site_version = is_string( $wp_version ) ? $wp_version : ''; + + if ( '' === $tested ) { + return $site_version; + } + + return 1 === version_compare( substr( $site_version, 0, 3 ), $tested ) ? $tested : $site_version; + } +} diff --git a/plugins/wpgraphql-logging/wpgraphql-logging.php b/plugins/wpgraphql-logging/wpgraphql-logging.php index 3492d641..c806c6d0 100644 --- a/plugins/wpgraphql-logging/wpgraphql-logging.php +++ b/plugins/wpgraphql-logging/wpgraphql-logging.php @@ -6,7 +6,7 @@ * Description: A WPGraphQL logging plugin that provides visibility into the request lifecycle, giving developers the observability needed to quickly identify and resolve bottlenecks in their headless WordPress application. * Author: WPEngine Headless OSS Team * Author URI: https://github.com/wpengine - * Update URI: https://github.com/wpengine/hwptoolkit + * Update URI: https://wpe-plugin-updates.wpengine.com/ * Version: 1.0.2 * Text Domain: wpgraphql-logging * Domain Path: /languages @@ -32,6 +32,7 @@ use WPGraphQL\Logging\Autoloader; use WPGraphQL\Logging\Plugin; +use WPGraphQL\Logging\Updates\PluginUpdater; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -177,6 +178,19 @@ static function (): void { } } +if ( ! function_exists( 'wpgraphql_logging_check_for_updates' ) ) { + /** + * Check the WP Engine plugin updates API for new versions of this plugin. + */ + function wpgraphql_logging_check_for_updates(): void { + // The slug must match the key in https://wpe-plugin-updates.wpengine.com/plugins.json. + PluginUpdater::check_for_updates( 'wpgraphql-logging', plugin_basename( __FILE__ ) ); + } +} + +// Priority 5 so the filters are in place before core's _maybe_update_plugins() runs on admin_init. +add_action( 'admin_init', 'wpgraphql_logging_check_for_updates', 5, 0 ); + /** * Load plugin text domain. */ From 7ad6246af1801c815970bf351c102a3dc8e17996 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:30:19 -0400 Subject: [PATCH 2/5] test(logging): cover the WP Engine updater client --- .../wpunit/Updates/PluginUpdaterTest.php | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php diff --git a/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php b/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php new file mode 100644 index 00000000..766168b2 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php @@ -0,0 +1,305 @@ +basename = plugin_basename( WPGRAPHQL_LOGGING_PLUGIN_DIR . 'wpgraphql-logging.php' ); + $this->requests = []; + $this->reset_state(); + } + + protected function tearDown(): void { + $this->reset_state(); + parent::tearDown(); + } + + private function reset_state(): void { + remove_all_filters( 'pre_http_request' ); + remove_all_filters( 'plugins_api' ); + remove_all_filters( 'pre_set_site_transient_update_plugins' ); + delete_option( 'wpesu-plugin-wpgraphql-logging' ); + delete_option( 'wpesu-plugin-wpgraphql-logging-expiry' ); + } + + /** + * Build a manifest like the one served by the updates API. + * + * @param array $overrides Fields to override. + */ + private function manifest( array $overrides = [] ): string { + $manifest = array_merge( + [ + 'name' => 'WPGraphQL Logging', + 'slug' => self::SLUG, + 'version' => '999.0.0', + 'requires' => '6.5', + 'requires_php' => '8.1.2', + 'tested' => '6.9', + 'download_link' => 'https://wpe-plugin-updates.wpengine.com/wpgraphql-logging/wpgraphql-logging.999.0.0.zip', + 'sections' => [ + 'description' => 'Description', + 'changelog' => 'Changelog', + ], + ], + $overrides + ); + + return (string) wp_json_encode( $manifest ); + } + + /** + * Short-circuit HTTP requests with the given response. + * + * @param array|\WP_Error $response The response to return. + */ + private function mock_http( $response ): void { + add_filter( + 'pre_http_request', + function ( $pre, $args, $url ) use ( $response ) { + $this->requests[] = $url; + return $response; + }, + 10, + 3 + ); + } + + private function mock_manifest( string $body, int $code = 200 ): void { + $this->mock_http( + [ + 'headers' => [], + 'body' => $body, + 'response' => [ + 'code' => $code, + 'message' => 200 === $code ? 'OK' : 'Error', + ], + 'cookies' => [], + 'filename' => null, + ] + ); + } + + private function updater(): PluginUpdater { + $updater = PluginUpdater::check_for_updates( self::SLUG, $this->basename ); + $this->assertInstanceOf( PluginUpdater::class, $updater ); + return $updater; + } + + private function empty_transient(): stdClass { + $transient = new stdClass(); + $transient->response = []; + $transient->checked = []; + return $transient; + } + + public function test_admin_init_hook_is_registered(): void { + $this->assertSame( 5, has_action( 'admin_init', 'wpgraphql_logging_check_for_updates' ) ); + } + + public function test_check_for_updates_function_registers_filters(): void { + wpgraphql_logging_check_for_updates(); + + $this->assertNotFalse( has_filter( 'pre_set_site_transient_update_plugins' ) ); + $this->assertNotFalse( has_filter( 'plugins_api' ) ); + } + + public function test_register_adds_filters(): void { + $updater = $this->updater(); + + $this->assertSame( 10, has_filter( 'pre_set_site_transient_update_plugins', [ $updater, 'filter_plugin_update_transient' ] ) ); + $this->assertSame( 20, has_filter( 'plugins_api', [ $updater, 'filter_plugin_update_info' ] ) ); + } + + public function test_returns_null_for_unknown_plugin(): void { + $this->assertNull( PluginUpdater::check_for_updates( self::SLUG, 'not-installed/not-installed.php' ) ); + $this->assertFalse( has_filter( 'pre_set_site_transient_update_plugins' ) ); + } + + public function test_returns_null_for_empty_properties(): void { + $this->assertNull( PluginUpdater::check_for_updates( '', $this->basename ) ); + $this->assertNull( PluginUpdater::check_for_updates( self::SLUG, '' ) ); + } + + public function test_manifest_url_and_cache_option_names(): void { + $updater = $this->updater(); + + $this->assertSame( 'https://wpe-plugin-updates.wpengine.com/wpgraphql-logging/info.json', $updater->get_manifest_url() ); + $this->assertSame( 'wpesu-plugin-wpgraphql-logging', $updater->get_cache_option_name() ); + $this->assertSame( 'wpesu-plugin-wpgraphql-logging-expiry', $updater->get_cache_expiry_option_name() ); + } + + public function test_transient_gets_update_when_newer_version_available(): void { + $this->mock_manifest( $this->manifest() ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertSame( [ 'https://wpe-plugin-updates.wpengine.com/wpgraphql-logging/info.json' ], $this->requests ); + $this->assertArrayHasKey( $this->basename, $transient->response ); + + $update = $transient->response[ $this->basename ]; + $this->assertSame( self::SLUG, $update->slug ); + $this->assertSame( '999.0.0', $update->new_version ); + $this->assertSame( $this->basename, $update->plugin ); + $this->assertSame( '8.1.2', $update->requires_php ); + $this->assertStringEndsWith( 'wpgraphql-logging.999.0.0.zip', $update->package ); + $this->assertSame( '999.0.0', $transient->checked[ $this->basename ] ); + } + + public function test_transient_unchanged_when_installed_version_is_current(): void { + $this->mock_manifest( $this->manifest( [ 'version' => WPGRAPHQL_LOGGING_VERSION ] ) ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertSame( [], $transient->response ); + } + + public function test_transient_without_response_property_is_handled(): void { + $this->mock_manifest( $this->manifest() ); + + $transient = $this->updater()->filter_plugin_update_transient( new stdClass() ); + + $this->assertArrayHasKey( $this->basename, $transient->response ); + } + + public function test_non_object_transient_is_returned_untouched(): void { + $this->mock_manifest( $this->manifest() ); + + $this->assertFalse( $this->updater()->filter_plugin_update_transient( false ) ); + $this->assertSame( [], $this->requests ); + } + + public function test_http_error_leaves_transient_unchanged(): void { + $this->mock_http( new WP_Error( 'http_request_failed', 'Timeout' ) ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertSame( [], $transient->response ); + $this->assertFalse( get_option( 'wpesu-plugin-wpgraphql-logging' ) ); + } + + public function test_non_200_response_leaves_transient_unchanged(): void { + $this->mock_manifest( $this->manifest(), 404 ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertSame( [], $transient->response ); + } + + public function test_invalid_json_leaves_transient_unchanged(): void { + $this->mock_manifest( 'not json' ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertSame( [], $transient->response ); + } + + public function test_manifest_missing_required_fields_is_ignored(): void { + $this->mock_manifest( $this->manifest( [ 'download_link' => '' ] ) ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertSame( [], $transient->response ); + } + + public function test_response_is_cached(): void { + $this->mock_manifest( $this->manifest() ); + $updater = $this->updater(); + + $updater->filter_plugin_update_transient( $this->empty_transient() ); + $updater->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertCount( 1, $this->requests ); + $this->assertSame( $this->manifest(), get_option( 'wpesu-plugin-wpgraphql-logging' ) ); + $this->assertGreaterThan( time(), (int) get_option( 'wpesu-plugin-wpgraphql-logging-expiry' ) ); + + $alloptions = wp_load_alloptions(); + $this->assertArrayNotHasKey( 'wpesu-plugin-wpgraphql-logging', $alloptions ); + } + + public function test_expired_cache_is_refreshed(): void { + update_option( 'wpesu-plugin-wpgraphql-logging', $this->manifest( [ 'version' => '1.0.0' ] ), false ); + update_option( 'wpesu-plugin-wpgraphql-logging-expiry', time() - 1, false ); + $this->mock_manifest( $this->manifest() ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertCount( 1, $this->requests ); + $this->assertSame( '999.0.0', $transient->response[ $this->basename ]->new_version ); + } + + public function test_plugins_api_returns_plugin_information(): void { + $this->mock_manifest( $this->manifest() ); + $args = (object) [ 'slug' => self::SLUG ]; + + $info = $this->updater()->filter_plugin_update_info( false, 'plugin_information', $args ); + + $this->assertInstanceOf( stdClass::class, $info ); + $this->assertSame( 'WPGraphQL Logging', $info->name ); + $this->assertSame( '999.0.0', $info->version ); + $this->assertSame( 'Changelog', $info->sections['changelog'] ); + $this->assertSame( 'Description', $info->sections['description'] ); + } + + public function test_plugins_api_ignores_other_actions_and_plugins(): void { + $this->mock_manifest( $this->manifest() ); + $updater = $this->updater(); + + $this->assertFalse( $updater->filter_plugin_update_info( false, 'query_plugins', (object) [ 'slug' => self::SLUG ] ) ); + $this->assertFalse( $updater->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => 'hello-dolly' ] ) ); + $this->assertSame( [], $this->requests ); + } + + public function test_plugins_api_passes_through_on_failure(): void { + $this->mock_manifest( '', 500 ); + + $this->assertFalse( $this->updater()->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ) ); + } + + public function test_tested_version_mapping(): void { + global $wp_version; + $original = $wp_version; + + $this->mock_manifest( $this->manifest( [ 'tested' => '6.5' ] ) ); + $updater = $this->updater(); + + // Site is on a newer major version than tested: report the tested version. + $wp_version = '6.8.1'; + $info = $updater->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ); + $this->assertSame( '6.5', $info->tested ); + + // Site is on the tested major version: report the site version. + $wp_version = '6.5.3'; + $info = $updater->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ); + $this->assertSame( '6.5.3', $info->tested ); + + $wp_version = $original; + } +} From b2271742d83118f8e0c242ab7c53ac21640de8db Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:34:24 -0400 Subject: [PATCH 3/5] fix(logging): harden the updater's transient, cache and cron handling Review against FaustWP and core's wp_update_plugins(): - Stop writing the remote version into $transient->checked. Core compares it with the installed version, so a pending update made every check look like a changed plugin and hit api.wordpress.org on each Plugins screen load. - Report the plugin in no_update when it is current so core can offer auto-updates, and remove it from the other list. - Only cache manifests that decode and validate. A 200 with a bad body was cached for five hours and blocked updates. - Keep the fetched manifest (or failure) for the request. Core saves the transient twice per check, which meant two 10s requests when the API was down. - Look up the installed version lazily instead of calling get_plugins() on every admin_init, including admin-ajax. - Make check_for_updates() idempotent and also run it on the wp_update_plugins and wp_maybe_auto_update cron actions, which never fire admin_init, so cron checks and auto-updates see the plugin. - Sanitize manifest fields (text, key, URL, kses for sections) and guard against non-scalar values. --- .../src/Updates/PluginUpdater.php | 261 +++++++++++------- .../wpunit/Updates/PluginUpdaterTest.php | 35 ++- .../wpgraphql-logging/wpgraphql-logging.php | 4 + 3 files changed, 192 insertions(+), 108 deletions(-) diff --git a/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php b/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php index 4a35cca7..288ebd29 100644 --- a/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php +++ b/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php @@ -65,14 +65,28 @@ class PluginUpdater { * * @var string */ - protected string $plugin_dirname = ''; + protected string $plugin_dirname; /** - * The installed plugin version. + * The installed plugin version. Null until looked up, empty if the plugin is not installed. * - * @var string + * @var string|null + */ + protected ?string $plugin_version = null; + + /** + * The manifest fetched during this request, so it is requested at most once per request. + * + * @var \stdClass|false|null Null until fetched, false if it could not be fetched. + */ + protected stdClass|false|null $manifest = null; + + /** + * Updaters registered during this request, keyed by plugin basename. + * + * @var array */ - protected string $plugin_version = ''; + protected static array $instances = []; /** * Constructor. @@ -83,46 +97,56 @@ class PluginUpdater { public function __construct( string $plugin_slug, string $plugin_basename ) { $this->plugin_slug = $plugin_slug; $this->plugin_basename = $plugin_basename; + $this->plugin_dirname = dirname( $plugin_basename ); } /** * Create an updater for the given plugin and register its hooks. * - * Intended to be called on `admin_init`. + * Safe to call more than once per request: later calls return the updater + * that is already registered for the plugin. * * @param string $plugin_slug The slug registered with the WP Engine plugin updates API. * @param string $plugin_basename The plugin basename as returned by plugin_basename(). * - * @return self|null The updater, or null if the plugin could not be found. + * @return self|null The updater, or null if the slug or basename is empty. */ public static function check_for_updates( string $plugin_slug, string $plugin_basename ): ?self { - $updater = new self( $plugin_slug, $plugin_basename ); + if ( '' === $plugin_slug || '' === $plugin_basename ) { + return null; + } - return $updater->register() ? $updater : null; + if ( ! isset( self::$instances[ $plugin_basename ] ) ) { + $updater = new self( $plugin_slug, $plugin_basename ); + $updater->register(); + self::$instances[ $plugin_basename ] = $updater; + } + + return self::$instances[ $plugin_basename ]; } /** - * Resolve the installed plugin and register the update filters. - * - * @return bool Whether the filters were registered. + * Register the update filters. */ - public function register(): bool { - if ( '' === $this->plugin_slug || '' === $this->plugin_basename ) { - return false; - } - - if ( ! $this->load_plugin_data() ) { - return false; - } - + public function register(): void { add_filter( 'plugins_api', [ $this, 'filter_plugin_update_info' ], 20, 3 ); add_filter( 'pre_set_site_transient_update_plugins', [ $this, 'filter_plugin_update_transient' ], 10, 1 ); + } - return true; + /** + * Remove the update filters. + */ + public function unregister(): void { + remove_filter( 'plugins_api', [ $this, 'filter_plugin_update_info' ], 20 ); + remove_filter( 'pre_set_site_transient_update_plugins', [ $this, 'filter_plugin_update_transient' ], 10 ); + unset( self::$instances[ $this->plugin_basename ] ); } /** - * Add this plugin to the update_plugins transient when a newer version is available. + * Report this plugin's update status in the update_plugins transient. + * + * Adds the plugin to `response` when a newer version is available, or to + * `no_update` when it is current (which lets core offer auto-updates). * * @param mixed $transient The value of the `update_plugins` site transient. * @@ -133,13 +157,21 @@ public function filter_plugin_update_transient( mixed $transient ): mixed { return $transient; } - $result = $this->fetch_plugin_info(); + $installed_version = $this->get_installed_version(); - if ( null === $result || ! version_compare( $this->plugin_version, $result->version, '<' ) ) { + if ( '' === $installed_version ) { return $transient; } - return $this->add_update_to_transient( $transient, $result ); + $manifest = $this->fetch_plugin_info(); + + if ( null === $manifest ) { + return $transient; + } + + $has_update = version_compare( $installed_version, $manifest->version, '<' ); + + return $this->set_transient_item( $transient, $this->parse_plugin_info( $manifest ), $has_update ); } /** @@ -160,13 +192,13 @@ public function filter_plugin_update_info( mixed $res, string $action, mixed $ar return $res; } - $result = $this->fetch_plugin_info(); + $manifest = $this->fetch_plugin_info(); - if ( null === $result ) { + if ( null === $manifest ) { return $res; } - return $this->parse_plugin_info( $result ); + return $this->parse_plugin_info( $manifest ); } /** @@ -191,71 +223,64 @@ public function get_cache_expiry_option_name(): string { } /** - * Look up the installed plugin and store its directory name and version. + * Look up the installed plugin version. * - * @return bool Whether the plugin was found. + * Done lazily, only when an update check runs, because get_plugins() reads + * every plugin's headers from disk. + * + * @return string The installed version, or an empty string if the plugin is not installed. */ - protected function load_plugin_data(): bool { - if ( ! function_exists( 'get_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; + protected function get_installed_version(): string { + if ( null !== $this->plugin_version ) { + return $this->plugin_version; } - $plugins = get_plugins(); - - if ( ! isset( $plugins[ $this->plugin_basename ] ) ) { - return false; + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; } - $this->plugin_dirname = dirname( $this->plugin_basename ); - $this->plugin_version = (string) $plugins[ $this->plugin_basename ]['Version']; + $plugins = get_plugins(); + $this->plugin_version = (string) ( $plugins[ $this->plugin_basename ]['Version'] ?? '' ); - return true; + return $this->plugin_version; } /** * Fetch the plugin manifest from the cache or the updates API. * + * The result, including a failure, is kept for the rest of the request so + * the API is called at most once even though core saves the update + * transient more than once per check. + * * @return \stdClass|null The decoded manifest, or null if it is unavailable or invalid. */ protected function fetch_plugin_info(): ?stdClass { - $body = $this->get_cached_manifest(); - - if ( null === $body ) { - $body = $this->request_manifest(); + if ( null === $this->manifest ) { + $this->manifest = $this->get_cached_manifest() ?? $this->request_manifest() ?? false; } - if ( null === $body ) { - return null; - } - - $decoded = json_decode( $body ); - - if ( ! $decoded instanceof stdClass || ! $this->is_valid_manifest( $decoded ) ) { - return null; - } - - return $decoded; + return false === $this->manifest ? null : $this->manifest; } /** - * Return the cached manifest body if it has not expired. + * Return the cached manifest if it has not expired and is still valid. */ - protected function get_cached_manifest(): ?string { + protected function get_cached_manifest(): ?stdClass { $expiry = (int) get_option( $this->get_cache_expiry_option_name(), 0 ); $body = get_option( $this->get_cache_option_name() ); - if ( time() > $expiry || ! is_string( $body ) || '' === $body ) { + if ( time() > $expiry || ! is_string( $body ) ) { return null; } - return $body; + return $this->decode_manifest( $body ); } /** - * Request the manifest from the updates API and cache a successful response. + * Request the manifest from the updates API and cache it if it is valid. */ - protected function request_manifest(): ?string { - // Admin-only and cached for CACHE_TTL, so the 10s timeout used by FaustWP is acceptable here. + protected function request_manifest(): ?stdClass { + // Only runs during update checks and is cached for CACHE_TTL, so the 10s timeout used by FaustWP is acceptable here. // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get $response = wp_remote_get( $this->get_manifest_url(), @@ -271,48 +296,61 @@ protected function request_manifest(): ?string { return null; } - $body = wp_remote_retrieve_body( $response ); + $body = wp_remote_retrieve_body( $response ); + $manifest = $this->decode_manifest( $body ); - if ( '' === $body ) { + if ( null === $manifest ) { return null; } update_option( $this->get_cache_expiry_option_name(), time() + self::CACHE_TTL, false ); update_option( $this->get_cache_option_name(), $body, false ); - return $body; + return $manifest; } /** - * Check that the manifest has the fields needed to offer an update. + * Decode a manifest body and check it has the fields needed to offer an update. * - * @param \stdClass $manifest The decoded manifest. + * @param string $body The raw JSON body. */ - protected function is_valid_manifest( stdClass $manifest ): bool { + protected function decode_manifest( string $body ): ?stdClass { + $manifest = json_decode( $body ); + + if ( ! $manifest instanceof stdClass ) { + return null; + } + foreach ( [ 'name', 'slug', 'version', 'download_link' ] as $field ) { if ( ! isset( $manifest->$field ) || ! is_string( $manifest->$field ) || '' === $manifest->$field ) { - return false; + return null; } } - return true; + return '' === esc_url_raw( $manifest->download_link, [ 'https', 'http' ] ) ? null : $manifest; } /** - * Add the update offer for this plugin to the update_plugins transient. + * Put the plugin's item in `response` or `no_update` and remove it from the other list. * - * @param \stdClass $transient The `update_plugins` site transient. - * @param \stdClass $manifest The decoded manifest. + * @param \stdClass $transient The `update_plugins` site transient. + * @param \stdClass $item The plugin update item. + * @param bool $has_update Whether a newer version is available. */ - protected function add_update_to_transient( stdClass $transient, stdClass $manifest ): stdClass { - $response = isset( $transient->response ) && is_array( $transient->response ) ? $transient->response : []; - $checked = isset( $transient->checked ) && is_array( $transient->checked ) ? $transient->checked : []; + protected function set_transient_item( stdClass $transient, stdClass $item, bool $has_update ): stdClass { + $add_to = $has_update ? 'response' : 'no_update'; + $remove_at = $has_update ? 'no_update' : 'response'; - $response[ $this->plugin_basename ] = $this->parse_plugin_info( $manifest ); - $checked[ $this->plugin_basename ] = $manifest->version; + $add_list = isset( $transient->$add_to ) && is_array( $transient->$add_to ) ? $transient->$add_to : []; - $transient->response = $response; - $transient->checked = $checked; + $add_list[ $this->plugin_basename ] = $item; + $transient->$add_to = $add_list; + + if ( isset( $transient->$remove_at ) && is_array( $transient->$remove_at ) ) { + $remove_list = $transient->$remove_at; + unset( $remove_list[ $this->plugin_basename ] ); + $transient->$remove_at = $remove_list; + } return $transient; } @@ -320,29 +358,62 @@ protected function add_update_to_transient( stdClass $transient, stdClass $manif /** * Map the manifest to the object WordPress expects for updates and plugin information. * - * @param \stdClass $manifest The decoded manifest. + * @param \stdClass $manifest The decoded, validated manifest. */ protected function parse_plugin_info( stdClass $manifest ): stdClass { + $download_link = esc_url_raw( $manifest->download_link, [ 'https', 'http' ] ); + $version = sanitize_text_field( $manifest->version ); + $res = new stdClass(); - $res->name = $manifest->name; - $res->slug = $manifest->slug; - $res->version = $manifest->version; - $res->requires = $manifest->requires ?? ''; - $res->requires_php = $manifest->requires_php ?? ''; - $res->download_link = $manifest->download_link; - $res->trunk = $manifest->download_link; - $res->new_version = $manifest->version; + $res->name = sanitize_text_field( $manifest->name ); + $res->slug = sanitize_key( $manifest->slug ); + $res->version = $version; + $res->new_version = $version; + $res->requires = $this->get_string( $manifest, 'requires' ); + $res->requires_php = $this->get_string( $manifest, 'requires_php' ); + $res->tested = $this->get_tested_version( $this->get_string( $manifest, 'tested' ) ); + $res->download_link = $download_link; + $res->trunk = $download_link; + $res->package = $download_link; $res->plugin = $this->plugin_basename; - $res->package = $manifest->download_link; - $res->tested = $this->get_tested_version( (string) ( $manifest->tested ?? '' ) ); - $res->sections = [ - 'description' => (string) ( $manifest->sections->description ?? '' ), - 'changelog' => (string) ( $manifest->sections->changelog ?? '' ), - ]; + $res->sections = $this->get_sections( $manifest ); return $res; } + /** + * Read the description and changelog sections from the manifest. + * + * @param \stdClass $manifest The decoded manifest. + * + * @return array + */ + protected function get_sections( stdClass $manifest ): array { + $sections = isset( $manifest->sections ) && $manifest->sections instanceof stdClass ? $manifest->sections : new stdClass(); + + return [ + 'description' => wp_kses_post( $this->get_string( $sections, 'description', false ) ), + 'changelog' => wp_kses_post( $this->get_string( $sections, 'changelog', false ) ), + ]; + } + + /** + * Read an optional scalar field from the manifest as a string. + * + * @param \stdClass $manifest The decoded manifest. + * @param string $field The field name. + * @param bool $as_text Whether to sanitize the value as plain text. + */ + protected function get_string( stdClass $manifest, string $field, bool $as_text = true ): string { + $value = $manifest->$field ?? ''; + + if ( ! is_scalar( $value ) ) { + return ''; + } + + return $as_text ? sanitize_text_field( (string) $value ) : (string) $value; + } + /** * Work out the "tested up to" version to report. * diff --git a/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php b/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php index 766168b2..96b4dadd 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php @@ -107,9 +107,9 @@ private function mock_manifest( string $body, int $code = 200 ): void { ); } - private function updater(): PluginUpdater { - $updater = PluginUpdater::check_for_updates( self::SLUG, $this->basename ); - $this->assertInstanceOf( PluginUpdater::class, $updater ); + private function updater( string $basename = '' ): PluginUpdater { + $updater = new PluginUpdater( self::SLUG, '' === $basename ? $this->basename : $basename ); + $updater->register(); return $updater; } @@ -126,9 +126,12 @@ public function test_admin_init_hook_is_registered(): void { public function test_check_for_updates_function_registers_filters(): void { wpgraphql_logging_check_for_updates(); + $updater = PluginUpdater::check_for_updates( self::SLUG, $this->basename ); + + $this->assertSame( 10, has_filter( 'pre_set_site_transient_update_plugins', [ $updater, 'filter_plugin_update_transient' ] ) ); + $this->assertSame( 20, has_filter( 'plugins_api', [ $updater, 'filter_plugin_update_info' ] ) ); - $this->assertNotFalse( has_filter( 'pre_set_site_transient_update_plugins' ) ); - $this->assertNotFalse( has_filter( 'plugins_api' ) ); + $updater->unregister(); } public function test_register_adds_filters(): void { @@ -138,9 +141,13 @@ public function test_register_adds_filters(): void { $this->assertSame( 20, has_filter( 'plugins_api', [ $updater, 'filter_plugin_update_info' ] ) ); } - public function test_returns_null_for_unknown_plugin(): void { - $this->assertNull( PluginUpdater::check_for_updates( self::SLUG, 'not-installed/not-installed.php' ) ); - $this->assertFalse( has_filter( 'pre_set_site_transient_update_plugins' ) ); + public function test_unknown_plugin_leaves_transient_unchanged(): void { + $this->mock_manifest( $this->manifest() ); + + $transient = $this->updater( 'not-installed/not-installed.php' )->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertSame( [], $transient->response ); + $this->assertSame( [], $this->requests ); } public function test_returns_null_for_empty_properties(): void { @@ -170,7 +177,7 @@ public function test_transient_gets_update_when_newer_version_available(): void $this->assertSame( $this->basename, $update->plugin ); $this->assertSame( '8.1.2', $update->requires_php ); $this->assertStringEndsWith( 'wpgraphql-logging.999.0.0.zip', $update->package ); - $this->assertSame( '999.0.0', $transient->checked[ $this->basename ] ); + $this->assertSame( [], $transient->checked, 'The checked list holds installed versions and must not be changed.' ); } public function test_transient_unchanged_when_installed_version_is_current(): void { @@ -179,6 +186,7 @@ public function test_transient_unchanged_when_installed_version_is_current(): vo $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); $this->assertSame( [], $transient->response ); + $this->assertArrayHasKey( $this->basename, $transient->no_update ); } public function test_transient_without_response_property_is_handled(): void { @@ -213,12 +221,13 @@ public function test_non_200_response_leaves_transient_unchanged(): void { $this->assertSame( [], $transient->response ); } - public function test_invalid_json_leaves_transient_unchanged(): void { + public function test_invalid_json_leaves_transient_unchanged_and_is_not_cached(): void { $this->mock_manifest( 'not json' ); $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); $this->assertSame( [], $transient->response ); + $this->assertFalse( get_option( 'wpesu-plugin-wpgraphql-logging' ) ); } public function test_manifest_missing_required_fields_is_ignored(): void { @@ -231,10 +240,10 @@ public function test_manifest_missing_required_fields_is_ignored(): void { public function test_response_is_cached(): void { $this->mock_manifest( $this->manifest() ); - $updater = $this->updater(); - $updater->filter_plugin_update_transient( $this->empty_transient() ); - $updater->filter_plugin_update_transient( $this->empty_transient() ); + $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + // A new updater (as on the next request) reads the cached option. + $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); $this->assertCount( 1, $this->requests ); $this->assertSame( $this->manifest(), get_option( 'wpesu-plugin-wpgraphql-logging' ) ); diff --git a/plugins/wpgraphql-logging/wpgraphql-logging.php b/plugins/wpgraphql-logging/wpgraphql-logging.php index c806c6d0..a7d76137 100644 --- a/plugins/wpgraphql-logging/wpgraphql-logging.php +++ b/plugins/wpgraphql-logging/wpgraphql-logging.php @@ -190,6 +190,10 @@ function wpgraphql_logging_check_for_updates(): void { // Priority 5 so the filters are in place before core's _maybe_update_plugins() runs on admin_init. add_action( 'admin_init', 'wpgraphql_logging_check_for_updates', 5, 0 ); +// Also register before core's cron update check and auto-updater, which run without admin_init. +/** @psalm-suppress HookNotFound */ +add_action( 'wp_update_plugins', 'wpgraphql_logging_check_for_updates', 5, 0 ); +add_action( 'wp_maybe_auto_update', 'wpgraphql_logging_check_for_updates', 5, 0 ); /** * Load plugin text domain. From f6bb156e97e7dea92445a1aeb2738746eab94f91 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:39:27 -0400 Subject: [PATCH 4/5] test(logging): cover updater edge cases and type the decoded manifest Running PHPStan at max level on the updater showed manifest fields were still read as mixed after validation. Decode the manifest once into an array of sanitized strings, then have the transient and plugins_api code read from that array instead of from the raw stdClass. Also guard the cached expiry and the installed version against non-scalar values. New wpunit coverage: - the cron hooks - check_for_updates() idempotency and unregister() - one request per page load after a failure - refetching an invalid cached body and skipping the request on a valid cache - rejecting non-HTTP download links and non-scalar fields - sanitized names, sections and optional fields - moving the plugin between response and no_update - the filters running through set_site_transient() and plugins_api() --- .../src/Updates/PluginUpdater.php | 123 ++++++------- .../wpunit/Updates/PluginUpdaterTest.php | 161 ++++++++++++++++++ 2 files changed, 224 insertions(+), 60 deletions(-) diff --git a/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php b/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php index 288ebd29..e082ca5e 100644 --- a/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php +++ b/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php @@ -77,9 +77,11 @@ class PluginUpdater { /** * The manifest fetched during this request, so it is requested at most once per request. * - * @var \stdClass|false|null Null until fetched, false if it could not be fetched. + * Null until fetched, false if it could not be fetched. + * + * @var array|false|null */ - protected stdClass|false|null $manifest = null; + protected array|false|null $manifest = null; /** * Updaters registered during this request, keyed by plugin basename. @@ -169,7 +171,7 @@ public function filter_plugin_update_transient( mixed $transient ): mixed { return $transient; } - $has_update = version_compare( $installed_version, $manifest->version, '<' ); + $has_update = version_compare( $installed_version, $manifest['version'], '<' ); return $this->set_transient_item( $transient, $this->parse_plugin_info( $manifest ), $has_update ); } @@ -239,8 +241,8 @@ protected function get_installed_version(): string { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } - $plugins = get_plugins(); - $this->plugin_version = (string) ( $plugins[ $this->plugin_basename ]['Version'] ?? '' ); + $version = get_plugins()[ $this->plugin_basename ]['Version'] ?? ''; + $this->plugin_version = is_string( $version ) ? $version : ''; return $this->plugin_version; } @@ -252,9 +254,9 @@ protected function get_installed_version(): string { * the API is called at most once even though core saves the update * transient more than once per check. * - * @return \stdClass|null The decoded manifest, or null if it is unavailable or invalid. + * @return array|null The manifest, or null if it is unavailable or invalid. */ - protected function fetch_plugin_info(): ?stdClass { + protected function fetch_plugin_info(): ?array { if ( null === $this->manifest ) { $this->manifest = $this->get_cached_manifest() ?? $this->request_manifest() ?? false; } @@ -264,12 +266,14 @@ protected function fetch_plugin_info(): ?stdClass { /** * Return the cached manifest if it has not expired and is still valid. + * + * @return array|null */ - protected function get_cached_manifest(): ?stdClass { - $expiry = (int) get_option( $this->get_cache_expiry_option_name(), 0 ); + protected function get_cached_manifest(): ?array { + $expiry = get_option( $this->get_cache_expiry_option_name(), 0 ); $body = get_option( $this->get_cache_option_name() ); - if ( time() > $expiry || ! is_string( $body ) ) { + if ( ! is_numeric( $expiry ) || time() > (int) $expiry || ! is_string( $body ) ) { return null; } @@ -278,8 +282,10 @@ protected function get_cached_manifest(): ?stdClass { /** * Request the manifest from the updates API and cache it if it is valid. + * + * @return array|null */ - protected function request_manifest(): ?stdClass { + protected function request_manifest(): ?array { // Only runs during update checks and is cached for CACHE_TTL, so the 10s timeout used by FaustWP is acceptable here. // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get $response = wp_remote_get( @@ -310,24 +316,40 @@ protected function request_manifest(): ?stdClass { } /** - * Decode a manifest body and check it has the fields needed to offer an update. + * Decode a manifest body into sanitized strings. * * @param string $body The raw JSON body. + * + * @return array|null The manifest, or null if it is invalid or missing a required field. */ - protected function decode_manifest( string $body ): ?stdClass { - $manifest = json_decode( $body ); + protected function decode_manifest( string $body ): ?array { + $data = json_decode( $body ); - if ( ! $manifest instanceof stdClass ) { + if ( ! $data instanceof stdClass ) { return null; } - foreach ( [ 'name', 'slug', 'version', 'download_link' ] as $field ) { - if ( ! isset( $manifest->$field ) || ! is_string( $manifest->$field ) || '' === $manifest->$field ) { + $sections = isset( $data->sections ) && $data->sections instanceof stdClass ? $data->sections : new stdClass(); + + $manifest = [ + 'name' => sanitize_text_field( $this->get_string( $data, 'name' ) ), + 'slug' => sanitize_key( $this->get_string( $data, 'slug' ) ), + 'version' => sanitize_text_field( $this->get_string( $data, 'version' ) ), + 'download_link' => esc_url_raw( $this->get_string( $data, 'download_link' ), [ 'https', 'http' ] ), + 'requires' => sanitize_text_field( $this->get_string( $data, 'requires' ) ), + 'requires_php' => sanitize_text_field( $this->get_string( $data, 'requires_php' ) ), + 'tested' => sanitize_text_field( $this->get_string( $data, 'tested' ) ), + 'description' => wp_kses_post( $this->get_string( $sections, 'description' ) ), + 'changelog' => wp_kses_post( $this->get_string( $sections, 'changelog' ) ), + ]; + + foreach ( [ 'name', 'slug', 'version', 'download_link' ] as $required ) { + if ( '' === $manifest[ $required ] ) { return null; } } - return '' === esc_url_raw( $manifest->download_link, [ 'https', 'http' ] ) ? null : $manifest; + return $manifest; } /** @@ -358,60 +380,41 @@ protected function set_transient_item( stdClass $transient, stdClass $item, bool /** * Map the manifest to the object WordPress expects for updates and plugin information. * - * @param \stdClass $manifest The decoded, validated manifest. + * @param array $manifest The decoded manifest. */ - protected function parse_plugin_info( stdClass $manifest ): stdClass { - $download_link = esc_url_raw( $manifest->download_link, [ 'https', 'http' ] ); - $version = sanitize_text_field( $manifest->version ); - + protected function parse_plugin_info( array $manifest ): stdClass { $res = new stdClass(); - $res->name = sanitize_text_field( $manifest->name ); - $res->slug = sanitize_key( $manifest->slug ); - $res->version = $version; - $res->new_version = $version; - $res->requires = $this->get_string( $manifest, 'requires' ); - $res->requires_php = $this->get_string( $manifest, 'requires_php' ); - $res->tested = $this->get_tested_version( $this->get_string( $manifest, 'tested' ) ); - $res->download_link = $download_link; - $res->trunk = $download_link; - $res->package = $download_link; + $res->name = $manifest['name']; + $res->slug = $manifest['slug']; + $res->version = $manifest['version']; + $res->new_version = $manifest['version']; + $res->requires = $manifest['requires']; + $res->requires_php = $manifest['requires_php']; + $res->tested = $this->get_tested_version( $manifest['tested'] ); + $res->download_link = $manifest['download_link']; + $res->trunk = $manifest['download_link']; + $res->package = $manifest['download_link']; $res->plugin = $this->plugin_basename; - $res->sections = $this->get_sections( $manifest ); + $res->sections = [ + 'description' => $manifest['description'], + 'changelog' => $manifest['changelog'], + ]; return $res; } /** - * Read the description and changelog sections from the manifest. + * Read a scalar field from a decoded JSON object as a string. * - * @param \stdClass $manifest The decoded manifest. + * @param \stdClass $data The decoded JSON object. + * @param string $field The field name. * - * @return array + * @return string The value, or an empty string if it is missing or not a scalar. */ - protected function get_sections( stdClass $manifest ): array { - $sections = isset( $manifest->sections ) && $manifest->sections instanceof stdClass ? $manifest->sections : new stdClass(); - - return [ - 'description' => wp_kses_post( $this->get_string( $sections, 'description', false ) ), - 'changelog' => wp_kses_post( $this->get_string( $sections, 'changelog', false ) ), - ]; - } - - /** - * Read an optional scalar field from the manifest as a string. - * - * @param \stdClass $manifest The decoded manifest. - * @param string $field The field name. - * @param bool $as_text Whether to sanitize the value as plain text. - */ - protected function get_string( stdClass $manifest, string $field, bool $as_text = true ): string { - $value = $manifest->$field ?? ''; - - if ( ! is_scalar( $value ) ) { - return ''; - } + protected function get_string( stdClass $data, string $field ): string { + $value = $data->$field ?? ''; - return $as_text ? sanitize_text_field( (string) $value ) : (string) $value; + return is_scalar( $value ) ? (string) $value : ''; } /** diff --git a/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php b/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php index 96b4dadd..33a60dd2 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php @@ -47,6 +47,11 @@ private function reset_state(): void { remove_all_filters( 'pre_set_site_transient_update_plugins' ); delete_option( 'wpesu-plugin-wpgraphql-logging' ); delete_option( 'wpesu-plugin-wpgraphql-logging-expiry' ); + + // Forget updaters registered by earlier tests or by admin_init during bootstrap. + $instances = new \ReflectionProperty( PluginUpdater::class, 'instances' ); + $instances->setAccessible( true ); + $instances->setValue( null, [] ); } /** @@ -311,4 +316,160 @@ public function test_tested_version_mapping(): void { $wp_version = $original; } + + public function test_cron_update_hooks_are_registered(): void { + $this->assertSame( 5, has_action( 'wp_update_plugins', 'wpgraphql_logging_check_for_updates' ) ); + $this->assertSame( 5, has_action( 'wp_maybe_auto_update', 'wpgraphql_logging_check_for_updates' ) ); + } + + public function test_check_for_updates_is_idempotent(): void { + $first = PluginUpdater::check_for_updates( self::SLUG, $this->basename ); + $second = PluginUpdater::check_for_updates( self::SLUG, $this->basename ); + + $this->assertSame( $first, $second ); + + global $wp_filter; + $this->assertCount( 1, $wp_filter['pre_set_site_transient_update_plugins']->callbacks[10] ); + } + + public function test_unregister_removes_filters(): void { + $updater = PluginUpdater::check_for_updates( self::SLUG, $this->basename ); + $updater->unregister(); + + $this->assertFalse( has_filter( 'pre_set_site_transient_update_plugins', [ $updater, 'filter_plugin_update_transient' ] ) ); + $this->assertFalse( has_filter( 'plugins_api', [ $updater, 'filter_plugin_update_info' ] ) ); + $this->assertNotSame( $updater, PluginUpdater::check_for_updates( self::SLUG, $this->basename ) ); + } + + public function test_failed_request_is_not_repeated_in_the_same_request(): void { + $this->mock_http( new WP_Error( 'http_request_failed', 'Timeout' ) ); + $updater = $this->updater(); + + // Core saves the update transient more than once per check. + $updater->filter_plugin_update_transient( $this->empty_transient() ); + $updater->filter_plugin_update_transient( $this->empty_transient() ); + $updater->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ); + + $this->assertCount( 1, $this->requests ); + } + + public function test_invalid_cached_body_is_refetched(): void { + update_option( 'wpesu-plugin-wpgraphql-logging', 'not json', false ); + update_option( 'wpesu-plugin-wpgraphql-logging-expiry', time() + HOUR_IN_SECONDS, false ); + $this->mock_manifest( $this->manifest() ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertCount( 1, $this->requests ); + $this->assertArrayHasKey( $this->basename, $transient->response ); + $this->assertSame( $this->manifest(), get_option( 'wpesu-plugin-wpgraphql-logging' ) ); + } + + public function test_valid_cache_skips_the_request(): void { + update_option( 'wpesu-plugin-wpgraphql-logging', $this->manifest(), false ); + update_option( 'wpesu-plugin-wpgraphql-logging-expiry', time() + HOUR_IN_SECONDS, false ); + $this->mock_manifest( '', 500 ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertSame( [], $this->requests ); + $this->assertArrayHasKey( $this->basename, $transient->response ); + } + + public function test_non_http_download_link_is_rejected(): void { + $this->mock_manifest( $this->manifest( [ 'download_link' => 'javascript:alert(1)' ] ) ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertSame( [], $transient->response ); + } + + public function test_non_scalar_required_field_is_rejected(): void { + $this->mock_manifest( $this->manifest( [ 'version' => [ '999.0.0' ] ] ) ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertSame( [], $transient->response ); + } + + public function test_manifest_fields_are_sanitized(): void { + $this->mock_manifest( + $this->manifest( + [ + 'name' => 'WPGraphQL Logging', + 'requires' => [ 'not', 'a', 'string' ], + 'sections' => [ + 'description' => '

Description

', + 'changelog' => [ 'not a string' ], + ], + ] + ) + ); + + $info = $this->updater()->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ); + + $this->assertSame( 'WPGraphQL Logging', $info->name ); + $this->assertSame( '', $info->requires ); + $this->assertSame( '

Description

alert(1)', $info->sections['description'] ); + $this->assertSame( '', $info->sections['changelog'] ); + } + + public function test_missing_sections_are_empty_strings(): void { + $manifest = json_decode( $this->manifest(), true ); + unset( $manifest['sections'], $manifest['requires_php'], $manifest['tested'] ); + $this->mock_manifest( (string) wp_json_encode( $manifest ) ); + + $info = $this->updater()->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ); + + $this->assertSame( [ 'description' => '', 'changelog' => '' ], $info->sections ); + $this->assertSame( '', $info->requires_php ); + $this->assertSame( $GLOBALS['wp_version'], $info->tested ); + } + + public function test_update_moves_plugin_from_no_update_to_response(): void { + $this->mock_manifest( $this->manifest() ); + $transient = $this->empty_transient(); + $transient->no_update[ $this->basename ] = (object) [ 'new_version' => WPGRAPHQL_LOGGING_VERSION ]; + + $transient = $this->updater()->filter_plugin_update_transient( $transient ); + + $this->assertArrayHasKey( $this->basename, $transient->response ); + $this->assertArrayNotHasKey( $this->basename, $transient->no_update ); + } + + public function test_current_version_moves_plugin_from_response_to_no_update(): void { + $this->mock_manifest( $this->manifest( [ 'version' => WPGRAPHQL_LOGGING_VERSION ] ) ); + $transient = $this->empty_transient(); + $transient->response[ $this->basename ] = (object) [ 'new_version' => '999.0.0' ]; + $transient->no_update = []; + + $transient = $this->updater()->filter_plugin_update_transient( $transient ); + + $this->assertArrayNotHasKey( $this->basename, $transient->response ); + $this->assertArrayHasKey( $this->basename, $transient->no_update ); + } + + public function test_saving_the_update_transient_runs_the_updater(): void { + $this->mock_manifest( $this->manifest() ); + $this->updater(); + + set_site_transient( 'update_plugins', $this->empty_transient() ); + $transient = get_site_transient( 'update_plugins' ); + + $this->assertSame( '999.0.0', $transient->response[ $this->basename ]->new_version ); + + delete_site_transient( 'update_plugins' ); + } + + public function test_plugins_api_returns_plugin_information_through_core(): void { + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + $this->mock_manifest( $this->manifest() ); + $this->updater(); + + $info = plugins_api( 'plugin_information', [ 'slug' => self::SLUG ] ); + + $this->assertNotWPError( $info ); + $this->assertSame( '999.0.0', $info->version ); + $this->assertSame( [ 'https://wpe-plugin-updates.wpengine.com/wpgraphql-logging/info.json' ], $this->requests ); + } } From 4c27783b21619fc5deaa9ae4dad440d2ee4f8a1e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:41:04 -0400 Subject: [PATCH 5/5] docs(logging): document plugin updates and tidy the updater tests - Add an Updates section and the src/Updates entry to the plugin docs: where updates come from, the five-hour cache, and how Composer-managed sites should keep updating. - Drop the redundant trailingslashit() calls when building the manifest URL. - Share the option names, manifest URL and plugins_api arguments across the updater tests instead of repeating the literals. --- docs/plugins/wpgraphql-logging/index.md | 9 +++ .../src/Updates/PluginUpdater.php | 2 +- .../wpunit/Updates/PluginUpdaterTest.php | 66 +++++++++++-------- 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/docs/plugins/wpgraphql-logging/index.md b/docs/plugins/wpgraphql-logging/index.md index 0231ad99..ac2b7d3e 100644 --- a/docs/plugins/wpgraphql-logging/index.md +++ b/docs/plugins/wpgraphql-logging/index.md @@ -112,6 +112,7 @@ wpgraphql-logging/ │ │ ├── Rules/ # Rules and RuleManager to decide whether to log a query │ │ ├── Scheduler/ # Automated data cleanup and maintenance tasks │ │ └── Store/ # Log storage service +│ ├── Updates/ # Client for the WP Engine plugin updates API │ ├── Plugin.php # Main plugin class (entry point) │ └── Autoloader.php # PSR-4 autoloader ├── tests/ # All test suites @@ -159,6 +160,14 @@ You can also filter logs with the following filters: You can delete selected logs or all logs using the bulk action controls. +## Updates + +The plugin checks the WP Engine plugin updates API for new versions, so new releases show up on the Plugins and Updates screens in wp-admin like any other plugin update, and you can turn on auto-updates for it. The plugin's `Update URI` header points at the updates API, so WordPress.org is never used for this plugin. + +The check runs when WordPress checks for plugin updates. It requests `https://wpe-plugin-updates.wpengine.com/wpgraphql-logging/info.json` and caches the response for five hours. If the API can't be reached, nothing changes and WordPress tries again on its next update check. + +If you manage the plugin with Composer, keep updating it with Composer so `composer.lock` stays in sync. wp-admin will still show available updates. + ## Uninstallation By default, WPGraphQL Logging preserves all logged data when the plugin is deactivated to prevent accidental data loss. diff --git a/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php b/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php index e082ca5e..f590d54d 100644 --- a/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php +++ b/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php @@ -207,7 +207,7 @@ public function filter_plugin_update_info( mixed $res, string $action, mixed $ar * The URL of this plugin's manifest on the updates API. */ public function get_manifest_url(): string { - return trailingslashit( self::API_URL ) . trailingslashit( $this->plugin_slug ) . 'info.json'; + return self::API_URL . $this->plugin_slug . '/info.json'; } /** diff --git a/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php b/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php index 33a60dd2..882a8e5b 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php @@ -20,6 +20,12 @@ class PluginUpdaterTest extends WPTestCase { private const SLUG = 'wpgraphql-logging'; + private const CACHE_OPTION = 'wpesu-plugin-wpgraphql-logging'; + + private const CACHE_EXPIRY_OPTION = 'wpesu-plugin-wpgraphql-logging-expiry'; + + private const MANIFEST_URL = 'https://wpe-plugin-updates.wpengine.com/wpgraphql-logging/info.json'; + /** * URLs requested through the HTTP API during a test. * @@ -45,8 +51,8 @@ private function reset_state(): void { remove_all_filters( 'pre_http_request' ); remove_all_filters( 'plugins_api' ); remove_all_filters( 'pre_set_site_transient_update_plugins' ); - delete_option( 'wpesu-plugin-wpgraphql-logging' ); - delete_option( 'wpesu-plugin-wpgraphql-logging-expiry' ); + delete_option( self::CACHE_OPTION ); + delete_option( self::CACHE_EXPIRY_OPTION ); // Forget updaters registered by earlier tests or by admin_init during bootstrap. $instances = new \ReflectionProperty( PluginUpdater::class, 'instances' ); @@ -118,6 +124,10 @@ private function updater( string $basename = '' ): PluginUpdater { return $updater; } + private function info_args( string $slug = self::SLUG ): stdClass { + return (object) [ 'slug' => $slug ]; + } + private function empty_transient(): stdClass { $transient = new stdClass(); $transient->response = []; @@ -163,9 +173,9 @@ public function test_returns_null_for_empty_properties(): void { public function test_manifest_url_and_cache_option_names(): void { $updater = $this->updater(); - $this->assertSame( 'https://wpe-plugin-updates.wpengine.com/wpgraphql-logging/info.json', $updater->get_manifest_url() ); - $this->assertSame( 'wpesu-plugin-wpgraphql-logging', $updater->get_cache_option_name() ); - $this->assertSame( 'wpesu-plugin-wpgraphql-logging-expiry', $updater->get_cache_expiry_option_name() ); + $this->assertSame( self::MANIFEST_URL, $updater->get_manifest_url() ); + $this->assertSame( self::CACHE_OPTION, $updater->get_cache_option_name() ); + $this->assertSame( self::CACHE_EXPIRY_OPTION, $updater->get_cache_expiry_option_name() ); } public function test_transient_gets_update_when_newer_version_available(): void { @@ -173,7 +183,7 @@ public function test_transient_gets_update_when_newer_version_available(): void $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); - $this->assertSame( [ 'https://wpe-plugin-updates.wpengine.com/wpgraphql-logging/info.json' ], $this->requests ); + $this->assertSame( [ self::MANIFEST_URL ], $this->requests ); $this->assertArrayHasKey( $this->basename, $transient->response ); $update = $transient->response[ $this->basename ]; @@ -215,7 +225,7 @@ public function test_http_error_leaves_transient_unchanged(): void { $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); $this->assertSame( [], $transient->response ); - $this->assertFalse( get_option( 'wpesu-plugin-wpgraphql-logging' ) ); + $this->assertFalse( get_option( self::CACHE_OPTION ) ); } public function test_non_200_response_leaves_transient_unchanged(): void { @@ -232,7 +242,7 @@ public function test_invalid_json_leaves_transient_unchanged_and_is_not_cached() $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); $this->assertSame( [], $transient->response ); - $this->assertFalse( get_option( 'wpesu-plugin-wpgraphql-logging' ) ); + $this->assertFalse( get_option( self::CACHE_OPTION ) ); } public function test_manifest_missing_required_fields_is_ignored(): void { @@ -251,16 +261,16 @@ public function test_response_is_cached(): void { $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); $this->assertCount( 1, $this->requests ); - $this->assertSame( $this->manifest(), get_option( 'wpesu-plugin-wpgraphql-logging' ) ); - $this->assertGreaterThan( time(), (int) get_option( 'wpesu-plugin-wpgraphql-logging-expiry' ) ); + $this->assertSame( $this->manifest(), get_option( self::CACHE_OPTION ) ); + $this->assertGreaterThan( time(), (int) get_option( self::CACHE_EXPIRY_OPTION ) ); $alloptions = wp_load_alloptions(); - $this->assertArrayNotHasKey( 'wpesu-plugin-wpgraphql-logging', $alloptions ); + $this->assertArrayNotHasKey( self::CACHE_OPTION, $alloptions ); } public function test_expired_cache_is_refreshed(): void { - update_option( 'wpesu-plugin-wpgraphql-logging', $this->manifest( [ 'version' => '1.0.0' ] ), false ); - update_option( 'wpesu-plugin-wpgraphql-logging-expiry', time() - 1, false ); + update_option( self::CACHE_OPTION, $this->manifest( [ 'version' => '1.0.0' ] ), false ); + update_option( self::CACHE_EXPIRY_OPTION, time() - 1, false ); $this->mock_manifest( $this->manifest() ); $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); @@ -271,7 +281,7 @@ public function test_expired_cache_is_refreshed(): void { public function test_plugins_api_returns_plugin_information(): void { $this->mock_manifest( $this->manifest() ); - $args = (object) [ 'slug' => self::SLUG ]; + $args = $this->info_args(); $info = $this->updater()->filter_plugin_update_info( false, 'plugin_information', $args ); @@ -286,15 +296,15 @@ public function test_plugins_api_ignores_other_actions_and_plugins(): void { $this->mock_manifest( $this->manifest() ); $updater = $this->updater(); - $this->assertFalse( $updater->filter_plugin_update_info( false, 'query_plugins', (object) [ 'slug' => self::SLUG ] ) ); - $this->assertFalse( $updater->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => 'hello-dolly' ] ) ); + $this->assertFalse( $updater->filter_plugin_update_info( false, 'query_plugins', $this->info_args() ) ); + $this->assertFalse( $updater->filter_plugin_update_info( false, 'plugin_information', $this->info_args( 'hello-dolly' ) ) ); $this->assertSame( [], $this->requests ); } public function test_plugins_api_passes_through_on_failure(): void { $this->mock_manifest( '', 500 ); - $this->assertFalse( $this->updater()->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ) ); + $this->assertFalse( $this->updater()->filter_plugin_update_info( false, 'plugin_information', $this->info_args() ) ); } public function test_tested_version_mapping(): void { @@ -306,12 +316,12 @@ public function test_tested_version_mapping(): void { // Site is on a newer major version than tested: report the tested version. $wp_version = '6.8.1'; - $info = $updater->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ); + $info = $updater->filter_plugin_update_info( false, 'plugin_information', $this->info_args() ); $this->assertSame( '6.5', $info->tested ); // Site is on the tested major version: report the site version. $wp_version = '6.5.3'; - $info = $updater->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ); + $info = $updater->filter_plugin_update_info( false, 'plugin_information', $this->info_args() ); $this->assertSame( '6.5.3', $info->tested ); $wp_version = $original; @@ -348,26 +358,26 @@ public function test_failed_request_is_not_repeated_in_the_same_request(): void // Core saves the update transient more than once per check. $updater->filter_plugin_update_transient( $this->empty_transient() ); $updater->filter_plugin_update_transient( $this->empty_transient() ); - $updater->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ); + $updater->filter_plugin_update_info( false, 'plugin_information', $this->info_args() ); $this->assertCount( 1, $this->requests ); } public function test_invalid_cached_body_is_refetched(): void { - update_option( 'wpesu-plugin-wpgraphql-logging', 'not json', false ); - update_option( 'wpesu-plugin-wpgraphql-logging-expiry', time() + HOUR_IN_SECONDS, false ); + update_option( self::CACHE_OPTION, 'not json', false ); + update_option( self::CACHE_EXPIRY_OPTION, time() + HOUR_IN_SECONDS, false ); $this->mock_manifest( $this->manifest() ); $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); $this->assertCount( 1, $this->requests ); $this->assertArrayHasKey( $this->basename, $transient->response ); - $this->assertSame( $this->manifest(), get_option( 'wpesu-plugin-wpgraphql-logging' ) ); + $this->assertSame( $this->manifest(), get_option( self::CACHE_OPTION ) ); } public function test_valid_cache_skips_the_request(): void { - update_option( 'wpesu-plugin-wpgraphql-logging', $this->manifest(), false ); - update_option( 'wpesu-plugin-wpgraphql-logging-expiry', time() + HOUR_IN_SECONDS, false ); + update_option( self::CACHE_OPTION, $this->manifest(), false ); + update_option( self::CACHE_EXPIRY_OPTION, time() + HOUR_IN_SECONDS, false ); $this->mock_manifest( '', 500 ); $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); @@ -406,7 +416,7 @@ public function test_manifest_fields_are_sanitized(): void { ) ); - $info = $this->updater()->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ); + $info = $this->updater()->filter_plugin_update_info( false, 'plugin_information', $this->info_args() ); $this->assertSame( 'WPGraphQL Logging', $info->name ); $this->assertSame( '', $info->requires ); @@ -419,7 +429,7 @@ public function test_missing_sections_are_empty_strings(): void { unset( $manifest['sections'], $manifest['requires_php'], $manifest['tested'] ); $this->mock_manifest( (string) wp_json_encode( $manifest ) ); - $info = $this->updater()->filter_plugin_update_info( false, 'plugin_information', (object) [ 'slug' => self::SLUG ] ); + $info = $this->updater()->filter_plugin_update_info( false, 'plugin_information', $this->info_args() ); $this->assertSame( [ 'description' => '', 'changelog' => '' ], $info->sections ); $this->assertSame( '', $info->requires_php ); @@ -470,6 +480,6 @@ public function test_plugins_api_returns_plugin_information_through_core(): void $this->assertNotWPError( $info ); $this->assertSame( '999.0.0', $info->version ); - $this->assertSame( [ 'https://wpe-plugin-updates.wpengine.com/wpgraphql-logging/info.json' ], $this->requests ); + $this->assertSame( [ self::MANIFEST_URL ], $this->requests ); } }