Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 );
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
<?php
/**
* Tests for the security scan diff baseline.
*
* @package WordPressdotorg\Plugin_Directory\Tests
*/

declare( strict_types = 1 );

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use WordPressdotorg\Plugin_Directory\Jobs\Plugin_Scan_Gandalf;
use WordPressdotorg\Plugin_Directory\Plugin_Directory;

/**
* Tests that scan dispatches baseline against the served release, so a release
* blocked by a scan can't become the diff baseline for its own re-release.
*
* Extends the plain PHPUnit TestCase: WP_UnitTestCase is not compatible with
* the PHPUnit 11 runner used by this suite. Isolation comes from giving every
* test its own plugin post instead of per-test transactions.
*
* The group is declared as an attribute as well as `@group`: PHPUnit 11 ignores
* a class-level `@group` docblock, while older runners ignore the attribute.
*
* @group jobs
*/
#[Group( 'jobs' )]
class Gandalf_Scan_Baseline_Test extends TestCase {

/** The version the update_source row serves — the last release users actually got. */
private const SERVED_VERSION = '1.4.3';

/** The version whose release was blocked by a scan — the previous import. */
private const BLOCKED_VERSION = '1.4.4';

/** The version being imported and scanned. */
private const NEW_VERSION = '1.4.5';

/**
* Counter to give every test plugin a unique slug.
*
* @var int
*/
private static int $plugin_count = 0;

/**
* The plugin post under test.
*
* @var \WP_Post
*/
private \WP_Post $plugin;

/**
* The scan request body captured from the dispatch, or null when none was sent.
*
* @var array|null
*/
private ?array $request_body = null;

/**
* The pre_http_request callback capturing the dispatch.
*
* @var \Closure
*/
private \Closure $http_mock;

/**
* Create a published plugin importing NEW_VERSION after a blocked BLOCKED_VERSION.
*/
protected function setUp(): void {
parent::setUp();

wp_cache_flush();

if ( ! defined( 'WP_GANDALF_SCAN_SHARED_SECRET' ) ) {
define( 'WP_GANDALF_SCAN_SHARED_SECRET', 'test-shared-secret' );
}

$plugin = Plugin_Directory::create_plugin_post(
array(
'post_name' => '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'] );
}
}