diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php index 575559210c..3aaf0eca40 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php @@ -55,16 +55,14 @@ static function display() { protected static function display_release_cooldown() { $post = get_post(); - $version = get_post_meta( $post->ID, 'version', true ); - if ( ! $version ) { - return; - } - - $release = Plugin_Directory::get_release( $post, $version ); + // Resolved from the stable tag, so the hold shows even when the Version header is empty or disagrees. + $release = API_Update_Updater::get_current_release( $post ); if ( ! $release ) { return; } + $release_version = $release['version']; + $release_delay = (int) ( $release['release_delay'] ?? 0 ); if ( ! $release_delay ) { return; @@ -82,7 +80,7 @@ protected static function display_release_cooldown() { printf( /* translators: 1: version, 2: relative time until cooldown expires, 3: absolute UTC timestamp */ esc_html__( 'Version %1$s is in the release cooldown — it will be served to sites in %2$s (at %3$s UTC).', 'wporg-plugins' ), - esc_html( $version ), + esc_html( $release_version ), esc_html( human_time_diff( time(), $cooldown_until ) ), esc_html( gmdate( 'Y-m-d H:i', $cooldown_until ) ) ); @@ -100,12 +98,12 @@ protected static function display_release_cooldown() { >

- @@ -121,7 +119,7 @@ protected static function display_release_cooldown() { * @param int $post_id The post being saved. */ public static function save_post( $post_id ) { - if ( empty( $_POST['force_release_version'] ) ) { + if ( empty( $_POST['force_release_tag'] ) ) { return; } @@ -138,10 +136,10 @@ public static function save_post( $post_id ) { // and to make the security boundary explicit. check_admin_referer( 'update-post_' . $post_id ); - $version = get_post_meta( $post->ID, 'version', true ); - $submitted_version = sanitize_text_field( wp_unslash( $_POST['force_release_version'] ) ); - if ( $submitted_version !== $version ) { - // Submitted version doesn't match current — a newer commit landed since the form was rendered. + // Staleness guard on the release identity: a commit that moved the stable tag since the form rendered lands a different release here. + $release = API_Update_Updater::get_current_release( $post ); + $submitted_tag = sanitize_text_field( wp_unslash( $_POST['force_release_tag'] ) ); + if ( ! $release || $submitted_tag !== (string) $release['tag'] ) { return; } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php index d1969f4c15..3b15906673 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php @@ -1722,7 +1722,18 @@ public static function add_release( $plugin, $data ) { $plugin = self::get_plugin_post( $plugin ); - $release = self::get_release( $plugin, $data['tag'] ) ?: [ + $releases = self::get_releases( $plugin ); + + // Strict match only: get_release()'s loose lookup ('1.4' == '1.40') could merge onto the wrong release. Only one release can exist in any given tag. + $release = false; + foreach ( $releases as $i => $r ) { + if ( isset( $r['tag'] ) && (string) $r['tag'] === $data['tag'] ) { + $release = $release ?: $r; + unset( $releases[ $i ] ); + } + } + + $release = $release ?: [ 'date' => time(), 'tag' => '', 'version' => '', @@ -1767,16 +1778,6 @@ public static function add_release( $plugin, $data ) { } unset( $release['unblock'] ); - $releases = self::get_releases( $plugin ); - - // Find any other releases using this slug (as in the case of updates) and remove it. - // Only one release can exist in any given tag. - foreach ( $releases as $i => $r ) { - if ( $r['tag'] === $release['tag'] ) { - unset( $releases[ $i ] ); - } - } - // Add this release in $releases[] = $release; 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 fba93c07d9..fc22e094fa 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 @@ -85,7 +85,7 @@ public static function update_single_plugin( $plugin_slug ) { $version = get_post_meta( $post->ID, 'version', true ); $requires_plugins = get_post_meta( $post->ID, 'requires_plugins', true ); - $release = Plugin_Directory::get_release( $post, $version ); + $release = self::get_current_release( $post ); $release_time = self::compute_release_time( $post, $release ); $existing_row = $wpdb->get_row( $wpdb->prepare( @@ -101,11 +101,13 @@ public static function update_single_plugin( $plugin_slug ) { $is_new_version = substr( (string) $version, 0, 128 ) !== $existing_version; /* - * Hold a blocked version out of the row: the previously served version keeps - * being served, and the deferred serve is cancelled rather than postponed. - * Status changes still reach the row right away. + * Hold a blocked release out of the row: the previous version keeps being + * served, the deferred serve is cancelled, and status changes still reach + * the row. Gated on the block alone — a header renamed to the served + * version turns the $is_new_version proxy false, and block_release() + * refuses served releases, so a held release is never already in the row. */ - if ( self::is_release_blocked( $release ) && $is_new_version ) { + if ( self::is_release_blocked( $release ) ) { wp_clear_scheduled_hook( "release_to_update_api:{$post->post_name}" ); if ( $existing_row ) { @@ -214,16 +216,6 @@ public static function update_single_plugin( $plugin_slug ) { return true; } - /** - * The version currently served from `update_source`. - * - * @param string $plugin_slug The 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`. * @@ -241,6 +233,58 @@ public static function get_served_release( $plugin_slug ) { ); } + /** + * The release being served or held for a plugin's current version. + * + * Resolved strictly by the stable tag — the source of the served package — + * so a Version header that disagrees with its tag cannot redirect a hold + * onto a different release. Trunk-stable plugins resolve by their + * `trunk@{version}` key instead; their identity is the header version. + * Strict matching, unlike Plugin_Directory::get_release()'s loose lookup + * (`'1.4' == '1.40'`), which could land on the wrong release. + * + * When the ref has no release row (a stable tag flipped to trunk at an + * unchanged version creates none), the version-named — then version- + * carrying — release is a fallback: a miss would fail the block and + * cooldown gates open, and the fallback never overrides a ref-resolved + * hold. + * + * @param \WP_Post $post The plugin post. + * @return array|false The matching release row, or false when none exists. + */ + public static function get_current_release( $post ) { + $stable_tag = get_post_meta( $post->ID, 'stable_tag', true ); + + $target = ( ! $stable_tag || 'trunk' === $stable_tag ) + ? 'trunk@' . get_post_meta( $post->ID, 'version', true ) + : $stable_tag; + + $releases = (array) Plugin_Directory::get_releases( $post ); + + foreach ( $releases as $release ) { + if ( isset( $release['tag'] ) && (string) $release['tag'] === (string) $target ) { + return $release; + } + } + + $version = (string) get_post_meta( $post->ID, 'version', true ); + if ( '' === $version ) { + return false; + } + + $carrying = false; + foreach ( $releases as $release ) { + if ( (string) ( $release['tag'] ?? '' ) === $version ) { + return $release; + } + if ( ! $carrying && (string) ( $release['version'] ?? '' ) === $version ) { + $carrying = $release; + } + } + + return $carrying; + } + /** * Whether a release is being held out of `update_source` by a block. * @@ -262,7 +306,7 @@ public static function is_release_blocked( $release ) { * version itself stays blocked until a reviewer force-releases it. * * The counterpart to force_release(). It refuses when the version cannot be - * held: no plugin, no release, or the version already being served. Blocking + * held: no plugin, no release, or the release already being served. Blocking * an already-held release is a no-op success that preserves the existing * block. Capability checks and audit logging are the caller's. * @@ -276,15 +320,24 @@ public static function block_release( $plugin_slug, array $block ) { return false; } - $version = get_post_meta( $post->ID, 'version', true ); - $release = Plugin_Directory::get_release( $post, $version ); + $release = self::get_current_release( $post ); if ( ! $release ) { return false; } - // Already live: a block can't un-ship a served version (compared with the column's varchar(128) truncation). - if ( self::get_served_version( $plugin_slug ) === substr( (string) $version, 0, 128 ) ) { - return false; + // Already live: a block can't un-ship a served release — compared by identity (the ref for tagged rows, the version for ref-less trunk-stable rows), with the columns' varchar(128) truncation. + $served = self::get_served_release( $plugin_slug ); + if ( $served ) { + if ( $served->stable_tag && 'trunk' !== $served->stable_tag ) { + $is_served = substr( (string) $release['tag'], 0, 128 ) === (string) $served->stable_tag; + } else { + $is_served = '' !== (string) $served->version + && substr( (string) ( $release['version'] ?? '' ), 0, 128 ) === (string) $served->version; + } + + if ( $is_served ) { + return false; + } } // Already held; recording a second block would merge it into the first. @@ -401,16 +454,24 @@ protected static function clear_plugin_caches( $plugin_slug ) { /** * Determine the release timestamp for a plugin version. * - * Falls back through the commit timestamp on the plugin post, and is replaced by the - * latest committer-confirmation time when release confirmations are required (the - * version isn't really "released" until the last confirmation lands). + * Anchored on the version's commit time (`version_date`), falling back to the + * release row's own date — unlike post_modified, neither slides on unrelated + * post edits — and replaced by the latest committer-confirmation time when + * release confirmations are required (the version isn't really "released" + * until the last confirmation lands). * * @param \WP_Post $post The plugin post. * @param array|bool $release The release row from Plugin_Directory::get_release(), or false. * @return int Unix timestamp. */ public static function compute_release_time( $post, $release ) { - $release_time = strtotime( $post->version_date ? $post->version_date : $post->post_modified ); + if ( $post->version_date ) { + $release_time = strtotime( $post->version_date ); + } elseif ( ! empty( $release['date'] ) ) { + $release_time = (int) $release['date']; + } else { + $release_time = strtotime( $post->post_modified ); + } if ( $release && @@ -467,13 +528,14 @@ public static function force_release( $plugin_slug, $reason, $user = null ) { return false; } - $version = get_post_meta( $post->ID, 'version', true ); - $release = Plugin_Directory::get_release( $post, $version ); + $release = self::get_current_release( $post ); if ( ! $release ) { return false; } + $version = $release['version']; + // Log only what is actually lifted: a deleted block's only trace, and the cooldown only while it still runs. $lifted = array(); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php index 862b1630b0..76cd7ef4b9 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php @@ -1,6 +1,7 @@ @@ -183,7 +187,7 @@ static function single_plugin( $plugin ) { implode( ', ', $data['committer'] ), ), self::get_actions( $plugin, $data ), - self::get_approval_text( $plugin, $data ) . + self::get_approval_text( $plugin, $data, $current_release ) . // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped when built. self::get_rollout_strategy( $plugin, $data ) ); } @@ -197,7 +201,15 @@ static function single_plugin( $plugin ) { '; } - static function get_approval_text( $plugin, $data ) { + /** + * The confirmation/cooldown status text for a release row. + * + * @param \WP_Post $plugin The plugin post object. + * @param array $data The release row from Plugin_Directory::get_releases(). + * @param array|null $current_release Optional. The already-resolved current release, to save re-resolving per row. + * @return string The release approval text, filtered via `wporg_plugins_release_approval_text`. + */ + public static function get_approval_text( $plugin, $data, $current_release = null ) { ob_start(); if ( ! $data['confirmations_required'] ) { @@ -265,7 +277,7 @@ static function get_approval_text( $plugin, $data ) { ); } - self::render_cooldown_status( $data ); + self::render_cooldown_status( $plugin, $data, $current_release ); echo ''; @@ -286,11 +298,14 @@ static function get_approval_text( $plugin, $data ) { * Render a single line describing the cooldown state of a release: pending serve time. * Skipped for releases without a cooldown delay (feature off at release creation, or * force-released), discarded releases, releases that haven't moved past - * confirmation/processing, or where the cooldown window has elapsed. + * confirmation/processing, rows other than the current release (superseded rows are + * never served), or where the cooldown window has elapsed. * - * @param array $data The release row from Plugin_Directory::get_releases(). + * @param \WP_Post $plugin The plugin post object. + * @param array $data The release row from Plugin_Directory::get_releases(). + * @param array|null $current_release The already-resolved current release, or null to resolve here. */ - protected static function render_cooldown_status( $data ) { + protected static function render_cooldown_status( $plugin, $data, $current_release = null ) { $release_delay = (int) ( $data['release_delay'] ?? 0 ); if ( ! $release_delay ) { return; @@ -305,8 +320,14 @@ protected static function render_cooldown_status( $data ) { return; } - $release_time = $data['confirmations'] ? max( $data['confirmations'] ) : (int) $data['date']; - $cooldown_until = $release_time + $release_delay; + // Only the current release can be pending; superseded rows are never served. + $current_release = $current_release ?? API_Update_Updater::get_current_release( $plugin ); + if ( ! $current_release || (string) ( $data['tag'] ?? '' ) !== (string) $current_release['tag'] ) { + return; + } + + // Match the enforced window: compute_release_time() is what update_single_plugin() gates on. + $cooldown_until = API_Update_Updater::compute_release_time( $plugin, $data ) + $release_delay; if ( $cooldown_until <= time() ) { return; @@ -482,12 +503,8 @@ public static function frontend_cooldown_notice( $post = null ) { return; } - $version = get_post_meta( $post->ID, 'version', true ); - if ( ! $version ) { - return; - } - - $release = Plugin_Directory::get_release( $post, $version ); + // Resolved from the stable tag, so the notice shows even when the Version header is empty or disagrees. + $release = API_Update_Updater::get_current_release( $post ); if ( ! $release ) { return; } @@ -497,8 +514,8 @@ public static function frontend_cooldown_notice( $post = null ) { return; } - $release_time = $release['confirmations'] ? max( $release['confirmations'] ) : (int) $release['date']; - $cooldown_until = $release_time + $release_delay; + // Match the enforced window: compute_release_time() is what update_single_plugin() gates on. + $cooldown_until = API_Update_Updater::compute_release_time( $post, $release ) + $release_delay; if ( $cooldown_until <= time() ) { return; @@ -510,7 +527,7 @@ public static function frontend_cooldown_notice( $post = null ) { sprintf( /* translators: 1: plugin version, 2: relative time until cooldown expires, 3: delay duration in hours, 4: plugins@wordpress.org link */ __( 'Version %1$s will be released to sites in about %2$s. WordPress.org currently delays plugin updates by %3$d hours so moderators and security scanners can review changes before they reach users. If this update fixes a security issue that needs to ship sooner, contact %4$s.', 'wporg-plugins' ), - '' . esc_html( $version ) . '', + '' . esc_html( $release['version'] ) . '', esc_html( human_time_diff( time(), $cooldown_until ) ), (int) ( $release_delay / HOUR_IN_SECONDS ), 'plugins@wordpress.org' diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Current_Release_Resolution_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Current_Release_Resolution_Test.php new file mode 100644 index 0000000000..a6209de163 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Current_Release_Resolution_Test.php @@ -0,0 +1,494 @@ + 'resolution-test-' . ( ++self::$plugin_count ), + 'post_title' => 'Release Resolution 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 ) ); + + $wpdb->insert( + $wpdb->prefix . 'update_source', + array( + 'plugin_id' => $this->plugin->ID, + 'plugin_slug' => $this->plugin->post_name, + 'available' => 1, + 'version' => self::SERVED_VERSION, + 'stable_tag' => self::SERVED_VERSION, + 'plugin_name' => $this->plugin->post_title, + 'requires_plugins' => '', + 'last_updated' => $this->plugin->post_modified, + ) + ); + + update_post_meta( $this->plugin->ID, 'version', self::RENAMED_VERSION ); + update_post_meta( $this->plugin->ID, 'stable_tag', self::HELD_TAG ); + } + + /** + * Register a release for a tag. + * + * @param string $tag The release tag. + * @param string $version The release version. + * @param array $overrides Fields to override. + */ + private function add_release( string $tag, string $version, array $overrides = array() ): void { + Plugin_Directory::add_release( + $this->plugin, + array_merge( + array( + 'tag' => $tag, + 'version' => $version, + 'zips_built' => true, + 'zips_built_from_revision' => 0, + 'confirmed' => true, + 'confirmations_required' => 0, + 'release_delay' => DAY_IN_SECONDS, + ), + $overrides + ) + ); + } + + /** + * The version currently in the plugin's update_source row. + * + * @return string The served version. + */ + private function served_version(): string { + return (string) ( API_Update_Updater::get_served_release( $this->plugin->post_name )->version ?? '' ); + } + + /** + * A renamed Version header inside a tag under cooldown keeps the hold. + */ + public function test_renamed_header_keeps_cooldown_hold(): void { + $this->add_release( self::HELD_TAG, self::RENAMED_VERSION ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $this->assertSame( self::SERVED_VERSION, $this->served_version() ); + $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); + } + + /** + * A renamed Version header inside a blocked tag keeps the block's hold. + */ + public function test_renamed_header_keeps_block_hold(): void { + $this->add_release( + self::HELD_TAG, + self::RENAMED_VERSION, + array( + 'release_block' => array( + 'scan_id' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'risk_score' => 9.8, + 'blocked_at' => time(), + ), + ) + ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $this->assertSame( self::SERVED_VERSION, $this->served_version() ); + $this->assertFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); + } + + /** + * A header renamed to another, clean release's version still resolves the + * hold from the stable tag serving the package. + */ + public function test_header_renamed_to_other_release_version_keeps_hold(): void { + $this->add_release( '2.0', '2.0', array( 'release_delay' => 0 ) ); + $this->add_release( + self::HELD_TAG, + self::RENAMED_VERSION, + array( + 'release_block' => array( 'blocked_at' => time() ), + ) + ); + update_post_meta( $this->plugin->ID, 'version', '2.0' ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $this->assertSame( self::SERVED_VERSION, $this->served_version() ); + } + + /** + * A scan-driven block lands on the stable tag's release despite the rename. + */ + public function test_block_release_records_block_on_stable_tag_release(): void { + $this->add_release( self::HELD_TAG, self::RENAMED_VERSION ); + + $this->assertTrue( API_Update_Updater::block_release( $this->plugin->post_name, array( 'risk_score' => 9.8 ) ) ); + + $release = Plugin_Directory::get_release( get_post( $this->plugin->ID ), self::HELD_TAG ); + $this->assertTrue( API_Update_Updater::is_release_blocked( $release ) ); + } + + /** + * A reviewer force-release lifts the hold on the stable tag's release and + * serves the version despite the rename. + */ + public function test_force_release_serves_renamed_version(): void { + $this->add_release( + self::HELD_TAG, + self::RENAMED_VERSION, + array( + 'release_block' => array( 'blocked_at' => time() ), + ) + ); + + $this->assertTrue( API_Update_Updater::force_release( $this->plugin->post_name, 'Reviewed; findings are a false positive.' ) ); + + $this->assertSame( self::RENAMED_VERSION, $this->served_version() ); + } + + /** + * Trunk releases keep resolving by version: their rows are keyed + * `trunk@{version}` and there is no stable tag release to prefer. + */ + public function test_trunk_release_still_resolves_by_version(): void { + update_post_meta( $this->plugin->ID, 'stable_tag', 'trunk' ); + $this->add_release( 'trunk@' . self::RENAMED_VERSION, self::RENAMED_VERSION ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $this->assertSame( self::SERVED_VERSION, $this->served_version() ); + $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); + } + + /** + * A release row for the header version is a fallback only: it resolves when + * the stable tag has no row (the gates would otherwise fail open), and never + * overrides the stable tag's own release. + */ + public function test_version_release_row_is_fallback_only(): void { + $this->add_release( self::RENAMED_VERSION, self::RENAMED_VERSION, array( 'release_block' => array( 'blocked_at' => time() ) ) ); + + $release = API_Update_Updater::get_current_release( get_post( $this->plugin->ID ) ); + $this->assertSame( self::RENAMED_VERSION, $release['tag'] ); + + $this->add_release( self::HELD_TAG, self::RENAMED_VERSION ); + + $release = API_Update_Updater::get_current_release( get_post( $this->plugin->ID ) ); + $this->assertSame( self::HELD_TAG, $release['tag'] ); + } + + /** + * Flipping the stable tag to trunk at an unchanged version creates no + * trunk@{version} release row; the hold on the version-named release must + * keep the gates closed rather than orphan into a fail-open miss. + */ + public function test_trunk_flip_at_same_version_keeps_hold(): void { + update_post_meta( $this->plugin->ID, 'version', self::RENAMED_VERSION ); + update_post_meta( $this->plugin->ID, 'stable_tag', 'trunk' ); + $this->add_release( self::RENAMED_VERSION, self::RENAMED_VERSION, array( 'release_block' => array( 'blocked_at' => time() ) ) ); + + $release = API_Update_Updater::get_current_release( get_post( $this->plugin->ID ) ); + $this->assertSame( self::RENAMED_VERSION, $release['tag'] ); + $this->assertTrue( API_Update_Updater::is_release_blocked( $release ) ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + $this->assertSame( self::SERVED_VERSION, $this->served_version() ); + } + + /** + * The fallback also survives the combination of both evasions: a renamed + * header inside the held tag, then a stable-tag flip to trunk. + */ + public function test_trunk_flip_with_renamed_header_keeps_hold(): void { + update_post_meta( $this->plugin->ID, 'stable_tag', 'trunk' ); + $this->add_release( self::HELD_TAG, self::RENAMED_VERSION, array( 'release_block' => array( 'blocked_at' => time() ) ) ); + + $release = API_Update_Updater::get_current_release( get_post( $this->plugin->ID ) ); + $this->assertSame( self::HELD_TAG, $release['tag'] ); + $this->assertTrue( API_Update_Updater::is_release_blocked( $release ) ); + } + + /** + * The block's already-served guard keys on the resolved release's identity, + * not the header: a header renamed to equal the served version cannot make + * a different, unserved release look already-live and refuse the block. + */ + public function test_block_release_guard_uses_resolved_release_version(): void { + update_post_meta( $this->plugin->ID, 'version', self::SERVED_VERSION ); + $this->add_release( self::HELD_TAG, '2.0' ); + + $this->assertTrue( API_Update_Updater::block_release( $this->plugin->post_name, array( 'risk_score' => 9.8 ) ) ); + + $release = Plugin_Directory::get_release( get_post( $this->plugin->ID ), self::HELD_TAG ); + $this->assertTrue( API_Update_Updater::is_release_blocked( $release ) ); + } + + /** + * A header renamed down to the already-served version cannot write the + * blocked tag into update_source: the block gate no longer keys on the + * header-derived is_new_version proxy. + */ + public function test_block_held_when_header_matches_served_version(): void { + global $wpdb; + + update_post_meta( $this->plugin->ID, 'version', self::SERVED_VERSION ); + $this->add_release( self::HELD_TAG, self::SERVED_VERSION, array( 'release_block' => array( 'blocked_at' => time() ) ) ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $row = $wpdb->get_row( $wpdb->prepare( "SELECT version, stable_tag FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", $this->plugin->post_name ) ); + $this->assertSame( self::SERVED_VERSION, $row->version ); + $this->assertSame( self::SERVED_VERSION, $row->stable_tag ); + } + + /** + * The already-served guard doesn't read an empty served version and an empty + * resolved version as a match, which would wrongly refuse the block. + */ + public function test_block_not_refused_for_empty_versions(): void { + global $wpdb; + + $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_slug' => $this->plugin->post_name ) ); + $this->add_release( self::HELD_TAG, '' ); + + $this->assertTrue( API_Update_Updater::block_release( $this->plugin->post_name, array( 'risk_score' => 9.8 ) ) ); + $this->assertTrue( API_Update_Updater::is_release_blocked( Plugin_Directory::get_release( get_post( $this->plugin->ID ), self::HELD_TAG ) ) ); + } + + /** + * A tag that is numerically equal but textually different to the stable tag + * does not resolve: the match is strict, not wp_list_filter's loose ==. + */ + public function test_numeric_collision_tag_not_matched(): void { + update_post_meta( $this->plugin->ID, 'stable_tag', '1.4' ); + $this->add_release( '1.40', '1.40', array( 'release_block' => array( 'blocked_at' => time() ) ) ); + + $this->assertFalse( API_Update_Updater::get_current_release( get_post( $this->plugin->ID ) ) ); + } + + /** + * A dormant SVN tag named like the trunk version does not shadow the + * `trunk@{version}` release the trunk plugin actually serves. + */ + public function test_trunk_version_not_shadowed_by_like_named_tag(): void { + update_post_meta( $this->plugin->ID, 'stable_tag', 'trunk' ); + update_post_meta( $this->plugin->ID, 'version', '1.2.3' ); + $this->add_release( '1.2.3', '1.2.3', array( 'release_block' => array( 'blocked_at' => time() ) ) ); + $this->add_release( 'trunk@1.2.3', '1.2.3' ); + + $release = API_Update_Updater::get_current_release( get_post( $this->plugin->ID ) ); + $this->assertSame( 'trunk@1.2.3', $release['tag'] ); + $this->assertFalse( API_Update_Updater::is_release_blocked( $release ) ); + } + + /** + * A re-commit into the already-served tag cannot be blocked, even when it + * bumps the header: the guard compares the ref the row serves, not the + * version label, so it can't claim to hold a package that already shipped. + */ + public function test_block_refused_for_recommit_into_served_tag(): void { + update_post_meta( $this->plugin->ID, 'stable_tag', self::SERVED_VERSION ); + update_post_meta( $this->plugin->ID, 'version', '1.0.1' ); + $this->add_release( self::SERVED_VERSION, '1.0.1' ); + + $this->assertFalse( API_Update_Updater::block_release( $this->plugin->post_name, array( 'risk_score' => 9.8 ) ) ); + $this->assertFalse( API_Update_Updater::is_release_blocked( Plugin_Directory::get_release( get_post( $this->plugin->ID ), self::SERVED_VERSION ) ) ); + } + + /** + * Trunk-stable rows carry no per-release ref, so the already-served guard + * falls back to their identity — the version — and still refuses the block. + */ + public function test_block_refused_for_served_trunk_release(): void { + global $wpdb; + + $wpdb->update( + $wpdb->prefix . 'update_source', + array( 'stable_tag' => 'trunk' ), + array( 'plugin_slug' => $this->plugin->post_name ) + ); + update_post_meta( $this->plugin->ID, 'stable_tag', 'trunk' ); + update_post_meta( $this->plugin->ID, 'version', self::SERVED_VERSION ); + $this->add_release( 'trunk@' . self::SERVED_VERSION, self::SERVED_VERSION ); + + $this->assertFalse( API_Update_Updater::block_release( $this->plugin->post_name, array( 'risk_score' => 9.8 ) ) ); + $this->assertFalse( API_Update_Updater::is_release_blocked( Plugin_Directory::get_release( get_post( $this->plugin->ID ), 'trunk@' . self::SERVED_VERSION ) ) ); + } + + /** + * The block's write lands on the exact tag: add_release() must not merge it + * onto a numerically-equal different release ('1.4' == '1.40') and delete + * the genuine row in the process. + */ + public function test_block_write_lands_on_exact_tag_not_numeric_equal(): void { + update_post_meta( $this->plugin->ID, 'stable_tag', '1.4' ); + $this->add_release( '1.40', '1.40', array( 'date' => time() ) ); + $this->add_release( '1.4', '1.4', array( 'date' => time() - DAY_IN_SECONDS ) ); + + $this->assertTrue( API_Update_Updater::block_release( $this->plugin->post_name, array( 'risk_score' => 9.8 ) ) ); + + $releases = array_column( Plugin_Directory::get_releases( $this->plugin ), null, 'tag' ); + $this->assertSame( '1.4', $releases['1.4']['version'] ); + $this->assertTrue( API_Update_Updater::is_release_blocked( $releases['1.4'] ) ); + $this->assertSame( '1.40', $releases['1.40']['version'] ); + $this->assertFalse( API_Update_Updater::is_release_blocked( $releases['1.40'] ) ); + } + + /** + * Without a version_date, the release clock anchors on the release row's own + * date: post_modified would slide on unrelated post edits and re-arm cooldown + * displays for an already-served release. + */ + public function test_release_time_anchors_on_release_date_without_version_date(): void { + $release = array( + 'date' => time() - WEEK_IN_SECONDS, + 'confirmations_required' => 0, + 'confirmations' => array(), + ); + + $this->assertSame( $release['date'], API_Update_Updater::compute_release_time( get_post( $this->plugin->ID ), $release ) ); + } + + /** + * A version_date on the plugin outranks the release row's date as the clock anchor. + */ + public function test_release_time_prefers_version_date(): void { + $version_time = time() - DAY_IN_SECONDS; + update_post_meta( $this->plugin->ID, 'version_date', gmdate( 'Y-m-d H:i:s', $version_time ) ); + + $release = array( + 'date' => time() - WEEK_IN_SECONDS, + 'confirmations_required' => 0, + 'confirmations' => array(), + ); + + $this->assertSame( $version_time, API_Update_Updater::compute_release_time( get_post( $this->plugin->ID ), $release ) ); + } + + /** + * The releases listing shows the cooldown line only for the current release: + * a superseded row is never served, so the plugin-wide version_date anchor + * must not re-display a pending serve time on old rows after a new commit. + */ + public function test_cooldown_line_only_for_current_release(): void { + update_post_meta( $this->plugin->ID, 'version_date', gmdate( 'Y-m-d H:i:s' ) ); + $this->add_release( self::HELD_TAG, self::RENAMED_VERSION ); + $this->add_release( '1.0', '1.0', array( 'date' => time() - WEEK_IN_SECONDS ) ); + + $releases = array_column( Plugin_Directory::get_releases( $this->plugin ), null, 'tag' ); + $plugin = get_post( $this->plugin->ID ); + + $this->assertStringContainsString( 'Will be served', Release_Confirmation::get_approval_text( $plugin, $releases[ self::HELD_TAG ] ) ); + $this->assertStringNotContainsString( 'Will be served', Release_Confirmation::get_approval_text( $plugin, $releases['1.0'] ) ); + } + + /** + * The listing's cooldown line follows the same resolution as the rest of the + * UI: with the stable tag flipped to trunk at an unchanged version, the + * fallback-resolved release is the current one and keeps its line. + */ + public function test_cooldown_line_follows_fallback_resolution(): void { + update_post_meta( $this->plugin->ID, 'version', self::RENAMED_VERSION ); + update_post_meta( $this->plugin->ID, 'stable_tag', 'trunk' ); + $this->add_release( self::RENAMED_VERSION, self::RENAMED_VERSION ); + + $releases = array_column( Plugin_Directory::get_releases( $this->plugin ), null, 'tag' ); + + $this->assertStringContainsString( 'Will be served', Release_Confirmation::get_approval_text( get_post( $this->plugin->ID ), $releases[ self::RENAMED_VERSION ] ) ); + } + + /** + * The force-release audit note names the release actually unblocked — the + * stable-tag-resolved one — not the plugin's Version header. + */ + public function test_force_release_audit_log_names_resolved_release(): void { + update_post_meta( $this->plugin->ID, 'version', '9.9.9' ); + $this->add_release( self::HELD_TAG, '7.7.7', array( 'release_block' => array( 'blocked_at' => time() ) ) ); + + $this->assertTrue( API_Update_Updater::force_release( $this->plugin->post_name, 'Reviewed.' ) ); + + $notes = get_comments( + array( + 'post_id' => $this->plugin->ID, + 'type' => 'internal-note', + ) + ); + $this->assertNotEmpty( $notes ); + $this->assertStringContainsString( 'version 7.7.7', $notes[0]->comment_content ); + $this->assertStringNotContainsString( '9.9.9', $notes[0]->comment_content ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Hold_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Hold_Test.php index bfea9d556b..0af9dbf8a0 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Hold_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Hold_Test.php @@ -290,7 +290,7 @@ public function test_block_holds_unserved_version(): void { $this->assertSame( 'High-risk release.', $block['reason'] ); $this->assertNotEmpty( $block['blocked_at'] ); - $this->assertSame( self::SERVED_VERSION, API_Update_Updater::get_served_version( $this->plugin->post_name ) ); + $this->assertSame( self::SERVED_VERSION, (string) ( $this->get_row()->version ?? '' ) ); $this->assertFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); } @@ -313,7 +313,7 @@ public function test_block_outlasts_cooldown(): void { API_Update_Updater::update_single_plugin( $this->plugin->post_name ); - $this->assertSame( self::SERVED_VERSION, API_Update_Updater::get_served_version( $this->plugin->post_name ) ); + $this->assertSame( self::SERVED_VERSION, (string) ( $this->get_row()->version ?? '' ) ); } /** @@ -334,7 +334,7 @@ public function test_block_holds_first_release(): void { API_Update_Updater::update_single_plugin( $this->plugin->post_name ); - $this->assertSame( '', API_Update_Updater::get_served_version( $this->plugin->post_name ) ); + $this->assertSame( '', (string) ( $this->get_row()->version ?? '' ) ); } /** @@ -453,7 +453,7 @@ public function test_force_release_clears_block(): void { $this->assertTrue( API_Update_Updater::force_release( $this->plugin->post_name, 'Reviewed; false positive.' ) ); $this->assertFalse( API_Update_Updater::is_release_blocked( $this->get_release() ) ); - $this->assertSame( self::STAGED_VERSION, API_Update_Updater::get_served_version( $this->plugin->post_name ) ); + $this->assertSame( self::STAGED_VERSION, (string) ( $this->get_row()->version ?? '' ) ); $audit_log = $this->get_audit_log(); $this->assertStringContainsString( 'lifting the release block', $audit_log ); @@ -471,7 +471,7 @@ public function test_force_release_clears_block_and_cooldown(): void { $this->assertTrue( API_Update_Updater::force_release( $this->plugin->post_name, 'Reviewed; false positive.' ) ); $this->assertFalse( API_Update_Updater::is_release_blocked( $this->get_release() ) ); - $this->assertSame( self::STAGED_VERSION, API_Update_Updater::get_served_version( $this->plugin->post_name ) ); + $this->assertSame( self::STAGED_VERSION, (string) ( $this->get_row()->version ?? '' ) ); $this->assertStringContainsString( 'lifting the release block and bypassing the 24-hour release cooldown',