diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php index c438c8e50b..fba93c07d9 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php @@ -221,11 +221,21 @@ public static function update_single_plugin( $plugin_slug ) { * @return string The served version, or '' when the plugin isn't in `update_source`. */ public static function get_served_version( $plugin_slug ) { + return (string) ( self::get_served_release( $plugin_slug )->version ?? '' ); + } + + /** + * The release currently served from `update_source`. + * + * @param string $plugin_slug The plugin slug. + * @return object|null Row with `version` and `stable_tag`, or null when the plugin isn't in `update_source`. + */ + public static function get_served_release( $plugin_slug ) { global $wpdb; - return (string) $wpdb->get_var( + return $wpdb->get_row( $wpdb->prepare( - "SELECT version FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", + "SELECT version, stable_tag FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", $plugin_slug ) ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php index a0b58c457d..68274af6ec 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php @@ -7,6 +7,7 @@ namespace WordPressdotorg\Plugin_Directory\Jobs; +use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Template; use WP_Error; use WP_Http; @@ -74,6 +75,19 @@ public static function dispatch_from_import_context( $plugin, $import_context ) $previous_version = get_post_meta( $plugin->ID, 'last_version', true ) ?: null; $previous_zip_url = null; + // A blocked or cooling-down release never reaches `update_source`, so diffing against the served release keeps a re-tagged payload from becoming its own baseline. + $served = API_Update_Updater::get_served_release( $plugin->post_name ); + if ( $served && substr( $version, 0, 128 ) !== $served->version ) { + $previous_release_ref = $served->stable_tag ?: null; + $previous_version = $served->version ?: null; + } + + // A blocked release must not be the baseline either way — dropping it makes the scanner run a full scan. + if ( $previous_release_ref && API_Update_Updater::is_release_blocked( Plugin_Directory::get_release( $plugin, $previous_release_ref ) ) ) { + $previous_release_ref = null; + $previous_version = null; + } + if ( $previous_release_ref && $previous_release_ref !== $release_ref && 'trunk' !== $previous_release_ref ) { $previous_zip_url = Template::download_link( $plugin, $previous_release_ref ); } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Baseline_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Baseline_Test.php new file mode 100644 index 0000000000..8e6cd296cc --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Baseline_Test.php @@ -0,0 +1,259 @@ + 'baseline-test-' . ( ++self::$plugin_count ), + 'post_title' => 'Scan Baseline Test Plugin', + 'post_status' => 'publish', + ) + ); + + $this->assertInstanceOf( \WP_Post::class, $plugin ); + $this->plugin = $plugin; + + /* + * The stub update_source table survives across runs — the WP test + * installer only drops core tables — so clear leftovers that would + * collide with this run's plugin ID or read as a served version. + */ + global $wpdb; + $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_id' => $this->plugin->ID ) ); + $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_slug' => $this->plugin->post_name ) ); + + update_post_meta( $this->plugin->ID, 'version', self::NEW_VERSION ); + update_post_meta( $this->plugin->ID, 'last_version', self::BLOCKED_VERSION ); + update_post_meta( $this->plugin->ID, 'last_stable_tag', self::BLOCKED_VERSION ); + + $this->request_body = null; + $this->http_mock = function ( mixed $preempt, array $parsed_args, string $url ): array { + $this->assertSame( Plugin_Scan_Gandalf::ENDPOINT, $url ); + $this->request_body = json_decode( $parsed_args['body'], true ); + + return array( + 'headers' => array(), + 'body' => wp_json_encode( array( 'scan_id' => $this->request_body['scan_id'] ) ), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }; + add_filter( 'pre_http_request', $this->http_mock, 10, 3 ); + } + + /** + * Unhook the HTTP mock. + */ + protected function tearDown(): void { + remove_filter( 'pre_http_request', $this->http_mock, 10 ); + + parent::tearDown(); + } + + /** + * Insert an update_source row serving a version. + * + * @param string $version The served version. + * @param string $stable_tag The served stable tag. + */ + private function stage_served_release( string $version, string $stable_tag ): void { + global $wpdb; + + $wpdb->insert( + $wpdb->prefix . 'update_source', + array( + 'plugin_id' => $this->plugin->ID, + 'plugin_slug' => $this->plugin->post_name, + 'available' => 1, + 'version' => $version, + 'stable_tag' => $stable_tag, + 'plugin_name' => $this->plugin->post_title, + 'requires_plugins' => '', + 'last_updated' => $this->plugin->post_modified, + ) + ); + } + + /** + * Dispatch a scan for NEW_VERSION and return the captured request body. + * + * @return array The captured scan request body. + */ + private function dispatch_scan(): array { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- A successful dispatch logs an E_USER_NOTICE; keep it out of the test output. + set_error_handler( static fn (): bool => true, E_USER_NOTICE ); + + try { + $dispatched = Plugin_Scan_Gandalf::dispatch_from_import_context( + $this->plugin, + array( + 'stable_tag' => self::NEW_VERSION, + 'old_stable_tag' => self::BLOCKED_VERSION, + 'changed_svn_tags' => array( self::NEW_VERSION ), + ) + ); + } finally { + restore_error_handler(); + } + + $this->assertTrue( $dispatched ); + $this->assertIsArray( $this->request_body ); + + return $this->request_body; + } + + /** + * With a version held out of update_source, the follow-up release is diffed + * against the served release, not the held one. + */ + public function test_served_release_is_baseline(): void { + $this->stage_served_release( self::SERVED_VERSION, self::SERVED_VERSION ); + + $request = $this->dispatch_scan(); + + $this->assertSame( self::NEW_VERSION, $request['version'] ); + $this->assertSame( self::SERVED_VERSION, $request['previous_version'] ); + $this->assertSame( self::SERVED_VERSION, $request['previous_release_ref'] ); + $this->assertSame( + 'https://downloads.wordpress.org/plugin/' . $this->plugin->post_name . '.' . self::SERVED_VERSION . '.zip', + $request['previous_zip_url'] + ); + } + + /** + * When the row already serves the scanned version — imports without a + * cooldown write it before the scan dispatches — the last imported release + * remains the baseline. + */ + public function test_falls_back_to_last_release_when_scanned_version_is_served(): void { + $this->stage_served_release( self::NEW_VERSION, self::NEW_VERSION ); + + $request = $this->dispatch_scan(); + + $this->assertSame( self::BLOCKED_VERSION, $request['previous_version'] ); + $this->assertSame( self::BLOCKED_VERSION, $request['previous_release_ref'] ); + } + + /** + * A plugin with no update_source row falls back to the last imported release. + */ + public function test_falls_back_to_last_release_without_served_release(): void { + $request = $this->dispatch_scan(); + + $this->assertSame( self::BLOCKED_VERSION, $request['previous_version'] ); + $this->assertSame( self::BLOCKED_VERSION, $request['previous_release_ref'] ); + } + + /** + * A baseline candidate that is itself a blocked release is dropped: with + * no served row, the last imported release would be the baseline, but the + * scan must run without one — a full scan — to surface its content. + */ + public function test_no_baseline_when_last_release_is_blocked(): void { + Plugin_Directory::add_release( + $this->plugin, + array( + 'tag' => self::BLOCKED_VERSION, + 'version' => self::BLOCKED_VERSION, + 'release_block' => array( 'blocked_at' => time() ), + ) + ); + + $request = $this->dispatch_scan(); + + $this->assertNull( $request['previous_version'] ); + $this->assertNull( $request['previous_release_ref'] ); + $this->assertNull( $request['previous_zip_url'] ); + } + + /** + * A trunk-served release has no retrievable previous ZIP — its build was + * overwritten — so the scan carries no baseline. + */ + public function test_no_baseline_when_serving_from_trunk(): void { + $this->stage_served_release( self::SERVED_VERSION, 'trunk' ); + + $request = $this->dispatch_scan(); + + $this->assertNull( $request['previous_version'] ); + $this->assertNull( $request['previous_release_ref'] ); + $this->assertNull( $request['previous_zip_url'] ); + } +}