diff --git a/docs/plugins/wpgraphql-logging/index.md b/docs/plugins/wpgraphql-logging/index.md index 0231ad998..ac2b7d3e5 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 new file mode 100644 index 000000000..f590d54d8 --- /dev/null +++ b/plugins/wpgraphql-logging/src/Updates/PluginUpdater.php @@ -0,0 +1,441 @@ +|false|null + */ + protected array|false|null $manifest = null; + + /** + * Updaters registered during this request, keyed by plugin basename. + * + * @var array + */ + protected static array $instances = []; + + /** + * Constructor. + * + * @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(). + */ + 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. + * + * 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 slug or basename is empty. + */ + public static function check_for_updates( string $plugin_slug, string $plugin_basename ): ?self { + if ( '' === $plugin_slug || '' === $plugin_basename ) { + return 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 ]; + } + + /** + * Register the update filters. + */ + 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 ); + } + + /** + * 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 ] ); + } + + /** + * 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. + * + * @return mixed The (possibly) modified transient. + */ + public function filter_plugin_update_transient( mixed $transient ): mixed { + if ( ! $transient instanceof stdClass ) { + return $transient; + } + + $installed_version = $this->get_installed_version(); + + if ( '' === $installed_version ) { + return $transient; + } + + $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 ); + } + + /** + * 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; + } + + $manifest = $this->fetch_plugin_info(); + + if ( null === $manifest ) { + return $res; + } + + return $this->parse_plugin_info( $manifest ); + } + + /** + * The URL of this plugin's manifest on the updates API. + */ + public function get_manifest_url(): string { + return self::API_URL . $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 version. + * + * 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 get_installed_version(): string { + if ( null !== $this->plugin_version ) { + return $this->plugin_version; + } + + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $version = get_plugins()[ $this->plugin_basename ]['Version'] ?? ''; + $this->plugin_version = is_string( $version ) ? $version : ''; + + 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 array|null The manifest, or null if it is unavailable or invalid. + */ + protected function fetch_plugin_info(): ?array { + if ( null === $this->manifest ) { + $this->manifest = $this->get_cached_manifest() ?? $this->request_manifest() ?? false; + } + + return false === $this->manifest ? null : $this->manifest; + } + + /** + * Return the cached manifest if it has not expired and is still valid. + * + * @return array|null + */ + 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 ( ! is_numeric( $expiry ) || time() > (int) $expiry || ! is_string( $body ) ) { + return null; + } + + return $this->decode_manifest( $body ); + } + + /** + * Request the manifest from the updates API and cache it if it is valid. + * + * @return array|null + */ + 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( + $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 ); + $manifest = $this->decode_manifest( $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 $manifest; + } + + /** + * 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 ): ?array { + $data = json_decode( $body ); + + if ( ! $data instanceof stdClass ) { + return null; + } + + $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 $manifest; + } + + /** + * 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 $item The plugin update item. + * @param bool $has_update Whether a newer version is available. + */ + 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'; + + $add_list = isset( $transient->$add_to ) && is_array( $transient->$add_to ) ? $transient->$add_to : []; + + $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; + } + + /** + * Map the manifest to the object WordPress expects for updates and plugin information. + * + * @param array $manifest The decoded manifest. + */ + protected function parse_plugin_info( array $manifest ): stdClass { + $res = new stdClass(); + $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 = [ + 'description' => $manifest['description'], + 'changelog' => $manifest['changelog'], + ]; + + return $res; + } + + /** + * Read a scalar field from a decoded JSON object as a string. + * + * @param \stdClass $data The decoded JSON object. + * @param string $field The field name. + * + * @return string The value, or an empty string if it is missing or not a scalar. + */ + protected function get_string( stdClass $data, string $field ): string { + $value = $data->$field ?? ''; + + return is_scalar( $value ) ? (string) $value : ''; + } + + /** + * 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/tests/wpunit/Updates/PluginUpdaterTest.php b/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php new file mode 100644 index 000000000..882a8e5b7 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Updates/PluginUpdaterTest.php @@ -0,0 +1,485 @@ +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( 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' ); + $instances->setAccessible( true ); + $instances->setValue( null, [] ); + } + + /** + * 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( string $basename = '' ): PluginUpdater { + $updater = new PluginUpdater( self::SLUG, '' === $basename ? $this->basename : $basename ); + $updater->register(); + 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 = []; + $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(); + $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' ] ) ); + + $updater->unregister(); + } + + 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_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 { + $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( 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 { + $this->mock_manifest( $this->manifest() ); + + $transient = $this->updater()->filter_plugin_update_transient( $this->empty_transient() ); + + $this->assertSame( [ self::MANIFEST_URL ], $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( [], $transient->checked, 'The checked list holds installed versions and must not be changed.' ); + } + + 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 ); + $this->assertArrayHasKey( $this->basename, $transient->no_update ); + } + + 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( self::CACHE_OPTION ) ); + } + + 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_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( self::CACHE_OPTION ) ); + } + + 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() ); + + $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( self::CACHE_OPTION ) ); + $this->assertGreaterThan( time(), (int) get_option( self::CACHE_EXPIRY_OPTION ) ); + + $alloptions = wp_load_alloptions(); + $this->assertArrayNotHasKey( self::CACHE_OPTION, $alloptions ); + } + + public function test_expired_cache_is_refreshed(): void { + 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() ); + + $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 = $this->info_args(); + + $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', $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', $this->info_args() ) ); + } + + 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', $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', $this->info_args() ); + $this->assertSame( '6.5.3', $info->tested ); + + $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', $this->info_args() ); + + $this->assertCount( 1, $this->requests ); + } + + public function test_invalid_cached_body_is_refetched(): void { + 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( self::CACHE_OPTION ) ); + } + + public function test_valid_cache_skips_the_request(): void { + 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() ); + + $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', $this->info_args() ); + + $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', $this->info_args() ); + + $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( [ self::MANIFEST_URL ], $this->requests ); + } +} diff --git a/plugins/wpgraphql-logging/wpgraphql-logging.php b/plugins/wpgraphql-logging/wpgraphql-logging.php index 3492d6413..a7d76137a 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,23 @@ 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 ); +// 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. */