diff --git a/environments/plugin-directory/.wp-env.test.json b/environments/plugin-directory/.wp-env.test.json
index 6bffb7da9c..145db9cad1 100644
--- a/environments/plugin-directory/.wp-env.test.json
+++ b/environments/plugin-directory/.wp-env.test.json
@@ -4,7 +4,13 @@
"plugins": [
"../wordpress.org/public_html/wp-content/plugins/plugin-directory"
],
+ "mappings": {
+ "wp-content/env-bin": "./plugin-directory/bin"
+ },
"lifecycleScripts": {
"afterStart": "bash plugin-directory/bin/after-start-test.sh"
+ },
+ "config": {
+ "PLUGINS_TABLE_PREFIX": "wp_"
}
}
diff --git a/environments/plugin-directory/bin/after-start-test.sh b/environments/plugin-directory/bin/after-start-test.sh
index 13d416d04a..b68d50afcb 100755
--- a/environments/plugin-directory/bin/after-start-test.sh
+++ b/environments/plugin-directory/bin/after-start-test.sh
@@ -1,7 +1,7 @@
#!/bin/bash
#
# Runs after wp-env start for the test environment.
-# Installs PHPUnit 11 and Yoast polyfills in the test container.
+# Installs PHPUnit 11 and Yoast polyfills, and creates the stub tables tests read.
#
CONFIG="--config plugin-directory/.wp-env.test.json"
@@ -10,3 +10,7 @@ RUN="npx wp-env $CONFIG run tests-cli"
echo "Installing PHPUnit 11 and polyfills..."
$RUN composer global require -W phpunit/phpunit:^11.0 2>&1
$RUN composer require --dev yoast/phpunit-polyfills:^4.0 --working-dir=/wordpress-phpunit 2>&1
+
+# Create stub database tables that exist outside WordPress on production.
+echo "Creating stub database tables..."
+$RUN -- wp db import wp-content/env-bin/database-tables.sql
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 447c8ac037..5960c0ab05 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -138,4 +138,14 @@
+
+
+
+
+
+
+
+
+
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 3d85569f28..b8ada657c3 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
@@ -46,11 +46,12 @@ static function display() {
}
/**
- * Display the release cooldown status and (for reviewers) a force-release control.
+ * Display the release hold status and (for reviewers) force-release and block controls.
*
- * Bails when there's no current release to gate, when the release has no cooldown
- * delay (feature off at release-creation, or already force-released), or when the
- * cooldown window has elapsed.
+ * Shows one of two messages: a countdown while a release is cooling down, or a block
+ * notice when the release is being held (which outlasts the cooldown window). Reviewers
+ * can force-release either way, and can block a version that's still cooling down. Bails
+ * when there's no current release, or when it's neither held nor still cooling down.
*/
protected static function display_release_cooldown() {
$post = get_post();
@@ -65,13 +66,13 @@ protected static function display_release_cooldown() {
return;
}
- $release_delay = (int) ( $release['release_delay'] ?? 0 );
- if ( ! $release_delay ) {
- return;
- }
-
+ $blocked = API_Update_Updater::is_release_blocked( $release );
+ $release_delay = (int) ( $release['release_delay'] ?? 0 );
$cooldown_until = API_Update_Updater::compute_release_time( $post, $release ) + $release_delay;
- if ( $cooldown_until <= time() ) {
+ $in_cooldown = $release_delay && $cooldown_until > time();
+
+ // Nothing to surface unless the release is held or still cooling down.
+ if ( ! $blocked && ! $in_cooldown ) {
return;
}
@@ -79,21 +80,39 @@ protected static function display_release_cooldown() {
@@ -116,12 +146,14 @@ protected static function display_release_cooldown() {
}
/**
- * Save handler for reviewer force-release submissions from the Controls metabox.
+ * Save handler for reviewer force-release and block submissions from the Controls metabox.
*
* @param int $post_id The post being saved.
*/
public static function save_post( $post_id ) {
- if ( empty( $_POST['force_release_version'] ) ) {
+ $is_force_release = ! empty( $_POST['force_release_version'] );
+ $is_block = ! empty( $_POST['block_release_version'] );
+ if ( ! $is_force_release && ! $is_block ) {
return;
}
@@ -139,20 +171,30 @@ public static function save_post( $post_id ) {
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'] ) );
+ $submitted_version = sanitize_text_field( wp_unslash( $is_force_release ? $_POST['force_release_version'] : $_POST['block_release_version'] ) );
if ( $submitted_version !== $version ) {
// Submitted version doesn't match current — a newer commit landed since the form was rendered.
return;
}
- $reason = isset( $_POST['force_release_reason'] )
- ? trim( sanitize_textarea_field( wp_unslash( $_POST['force_release_reason'] ) ) )
+ $reason = isset( $_POST['release_action_reason'] )
+ ? trim( sanitize_textarea_field( wp_unslash( $_POST['release_action_reason'] ) ) )
: '';
if ( ! $reason ) {
return;
}
- API_Update_Updater::force_release( $post->post_name, $reason );
+ if ( $is_force_release ) {
+ API_Update_Updater::force_release( $post->post_name, $reason );
+ } else {
+ API_Update_Updater::block_release(
+ $post->post_name,
+ array(
+ 'reason' => $reason,
+ 'blocked_by' => wp_get_current_user()->user_login,
+ )
+ );
+ }
}
/**
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 105c678dfc..d889980d83 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
@@ -1754,6 +1754,15 @@ public static function add_release( $plugin, $data ) {
unset( $release['discarded'] );
}
+ /*
+ * Clear a high-risk Gandalf block so the release can be served.
+ * See Jobs\API_Update_Updater::force_release().
+ */
+ if ( ! empty( $data['unblock'] ) ) {
+ unset( $release['release_block'] );
+ }
+ unset( $release['unblock'] );
+
$releases = self::get_releases( $plugin );
// Find any other releases using this slug (as in the case of updates) and remove it.
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 f24821258a..491da03b88 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
@@ -86,15 +86,16 @@ public static function update_single_plugin( $plugin_slug ) {
$requires_plugins = get_post_meta( $post->ID, 'requires_plugins', true );
$release = Plugin_Directory::get_release( $post, $version );
$release_time = self::compute_release_time( $post, $release );
- $existing_version = (string) $wpdb->get_var(
- $wpdb->prepare(
- "SELECT version FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
- $post->post_name
- )
- );
+ $existing_version = self::get_served_version( $post->post_name );
$release_delay = (int) ( $release['release_delay'] ?? 0 );
+ if ( self::is_release_blocked( $release ) && $existing_version !== (string) $version ) {
+ wp_clear_scheduled_hook( "release_to_update_api:{$post->post_name}" );
+
+ return true;
+ }
+
/*
* Defer the write for new versions still inside the cooldown window. While
* deferred, the existing `update_source` row (carrying the previous version)
@@ -198,7 +199,7 @@ public static function update_single_plugin( $plugin_slug ) {
// Sync the latest version to Stats.
if ( function_exists( '\WordPressdotorg\Stats\sync_latest_version' ) ) {
\WordPressdotorg\Stats\sync_latest_version(
- 'plugin',
+ 'plugin',
array(
$plugin_slug => $version
)
@@ -208,6 +209,35 @@ 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 ) {
+ global $wpdb;
+
+ return (string) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT version FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
+ $plugin_slug
+ )
+ );
+ }
+
+ /**
+ * Whether a release is being held out of `update_source` by a block.
+ *
+ * A block is set either by a high-risk security scan or by a reviewer.
+ *
+ * @param array|bool $release The release row from Plugin_Directory::get_release(), or false.
+ * @return bool True when the release is being held out of `update_source`.
+ */
+ public static function is_release_blocked( $release ) {
+ return is_array( $release ) && ! empty( $release['release_block'] );
+ }
+
/**
* Determine the release timestamp for a plugin version.
*
@@ -283,27 +313,113 @@ public static function force_release( $plugin_slug, $reason, $user = null ) {
return false;
}
- Tools::audit_log(
- sprintf(
- 'Force-released version %s, bypassing the %d-hour release cooldown. Reason: %s',
- $version,
- (int) ( $release['release_delay'] ?? 0 ) / HOUR_IN_SECONDS,
- $reason
- ),
- $post
- );
+ // A force-release also overrides a block; note that in the audit trail.
+ if ( self::is_release_blocked( $release ) ) {
+ $block = $release['release_block'];
+ if ( isset( $block['risk_score'] ) ) {
+ $message = sprintf(
+ 'Force-released version %1$s, overriding the security-scan block (risk score %2$s). Reason: %3$s',
+ $version,
+ $block['risk_score'],
+ $reason
+ );
+ } else {
+ $message = sprintf(
+ 'Force-released version %1$s, overriding the release block. Reason: %2$s',
+ $version,
+ $reason
+ );
+ }
+ Tools::audit_log( $message, $post );
+ } else {
+ Tools::audit_log(
+ sprintf(
+ 'Force-released version %s, bypassing the %d-hour release cooldown. Reason: %s',
+ $version,
+ (int) ( $release['release_delay'] ?? 0 ) / HOUR_IN_SECONDS,
+ $reason
+ ),
+ $post
+ );
+ }
Plugin_Directory::add_release(
$post,
array(
'tag' => $release['tag'],
'release_delay' => 0,
+ // Clear any Gandalf block so update_single_plugin() serves the version.
+ 'unblock' => true,
)
);
return self::update_single_plugin( $plugin_slug );
}
+ /**
+ * Hold a plugin's current version out of `update_source` until it's force-released.
+ *
+ * The counterpart to force_release(), shared by the reviewer control and the security
+ * scan. Callers apply their own preconditions first; this only refuses when there's
+ * nothing left to hold.
+ *
+ * Capability checks must be performed by the caller.
+ *
+ * @param string $plugin_slug The plugin slug.
+ * @param array $block The block to record: 'reason' and 'blocked_by' for a reviewer
+ * block, or 'scan_id' and 'risk_score' for a security scan.
+ * @return bool True when the version was held, false when there was nothing to hold.
+ */
+ public static function block_release( $plugin_slug, $block ) {
+ $post = Plugin_Directory::get_plugin_post( $plugin_slug );
+ if ( ! $post ) {
+ return false;
+ }
+
+ $version = get_post_meta( $post->ID, 'version', true );
+ $release = Plugin_Directory::get_release( $post, $version );
+
+ if ( ! $release ) {
+ return false;
+ }
+
+ // Already live: the version is being served, so there's nothing left to hold back.
+ if ( self::get_served_version( $plugin_slug ) === (string) $version ) {
+ return false;
+ }
+
+ $block['blocked_at'] = time();
+
+ Plugin_Directory::add_release(
+ $post,
+ array(
+ 'tag' => $release['tag'],
+ 'release_block' => $block,
+ )
+ );
+
+ if ( isset( $block['risk_score'] ) ) {
+ $message = sprintf(
+ 'A security scan blocked version %1$s from being served (risk score %2$s).',
+ $version,
+ $block['risk_score']
+ );
+ } else {
+ $message = sprintf(
+ 'Blocked version %1$s from being served to sites. Reason: %2$s',
+ $version,
+ $block['reason']
+ );
+ }
+
+ Tools::audit_log( $message, $post );
+
+ // Re-run so a version scheduled to serve at cooldown-end is held now instead.
+ self::update_single_plugin( $plugin_slug );
+
+ return true;
+ }
+
static function get_plugin_assets( $post ) {
$icons = $banners = $banners_rtl = array();
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 aacb3d77af..0e17f14c4e 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;
@@ -30,6 +31,9 @@ class Plugin_Scan_Gandalf {
/** Gandalf scan endpoint. */
const ENDPOINT = 'https://gandalf.wordpress.org/scan';
+ /** Risk score at or above which a completed scan blocks the release from being served. */
+ const RISK_SCORE_BLOCK_THRESHOLD = 8;
+
/**
* Dispatch a Gandalf scan from the importer context carried through cron.
*
@@ -178,8 +182,33 @@ public static function handle_callback( $plugin, $data ) {
}
if ( 'completed' === $data['status'] ) {
- if ( $data['findings_count'] > 0 ) {
- self::notify_slack(
+ /*
+ * Precedence: a high enough risk score blocks the release outright; otherwise findings
+ * raise the usual advisory alert. `risk_score` is read defensively because the callback
+ * route doesn't validate it, and because Gandalf doesn't send it yet.
+ */
+ $risk_score = ( isset( $data['risk_score'] ) && is_numeric( $data['risk_score'] ) ) ? (float) $data['risk_score'] : null;
+
+ if ( null !== $risk_score && $risk_score >= self::RISK_SCORE_BLOCK_THRESHOLD ) {
+ /*
+ * Auto-blocking is disabled. Enable by restoring:
+ *
+ * $held = self::block_release( $plugin, $pending_record, $scan_id, $risk_score );
+ */
+ $held = false;
+
+ self::notify_slack_blocked(
+ $plugin,
+ [
+ 'version' => $pending_record['version'],
+ 'release_ref' => $pending_record['release_ref'],
+ 'risk_score' => $risk_score,
+ 'held' => $held,
+ 'report_url' => $data['report_url'] ?? '',
+ ]
+ );
+ } elseif ( $data['findings_count'] > 0 ) {
+ self::notify_slack_findings(
$plugin,
[
'version' => $pending_record['version'],
@@ -201,6 +230,42 @@ public static function handle_callback( $plugin, $data ) {
return true;
}
+ /**
+ * Hold the scanned release, once the verdict is known to still apply to it.
+ *
+ * @param \WP_Post $plugin The plugin post.
+ * @param array $pending_record The pending scan record (version, release_ref).
+ * @param string $scan_id The Gandalf scan ID.
+ * @param float $risk_score The reported risk score.
+ * @return bool True when the release was held, false when there was nothing to hold.
+ */
+ protected static function block_release( $plugin, $pending_record, $scan_id, $risk_score ) {
+ $version = (string) $pending_record['version'];
+
+ // A newer release landed since this scan was dispatched; the scanned version is moot.
+ if ( (string) get_post_meta( $plugin->ID, 'version', true ) !== $version ) {
+ return false;
+ }
+
+ $release = Plugin_Directory::get_release( $plugin, $version );
+ if ( ! $release ) {
+ return false;
+ }
+
+ // No cooldown was captured at release creation, so the version was served at import.
+ if ( empty( $release['release_delay'] ) ) {
+ return false;
+ }
+
+ return API_Update_Updater::block_release(
+ $plugin->post_name,
+ [
+ 'scan_id' => $scan_id,
+ 'risk_score' => $risk_score,
+ ]
+ );
+ }
+
/**
* Record a valid-secret callback that failed validation.
*
@@ -236,12 +301,16 @@ protected static function dispatch_failed( $plugin, $request_data, $message, $ki
}
/**
- * Notify Slack about a Gandalf scan with findings.
+ * Alert Slack about a scan that reported findings.
+ *
+ * Dedupes on the verdict hash so an unchanged result isn't reported twice, and is skipped
+ * when no hash is present, since there's nothing to dedupe on.
*
* @param \WP_Post $plugin The plugin post.
- * @param array $record The completed scan summary.
+ * @param array $record 'version', 'release_ref', 'findings_count', 'severity_counts',
+ * 'verdict_hash', 'report_url'.
*/
- protected static function notify_slack( $plugin, $record ) {
+ protected static function notify_slack_findings( $plugin, $record ) {
if ( empty( $record['verdict_hash'] ) ) {
return;
}
@@ -261,46 +330,93 @@ protected static function notify_slack( $plugin, $record ) {
$already_notified[ $record['verdict_hash'] ] = time();
update_post_meta( $plugin->ID, self::NOTIFIED_META_KEY, $already_notified );
- if ( ! defined( 'PLUGIN_REVIEW_ALERT_SLACK_CHANNEL' ) || ! function_exists( 'slack_dm' ) ) {
- return;
- }
+ $detail = [ sprintf( 'Findings: %d', $record['findings_count'] ) ];
- $active_installs = (int) get_post_meta( $plugin->ID, 'active_installs', true );
- $install_line = sprintf( '%s+ active installs', number_format_i18n( $active_installs ) );
- if ( $active_installs >= 10000 ) {
- $install_line = ":bangbang::bangbang::bangbang: {$install_line} :bangbang::bangbang::bangbang:";
+ $severity_summary = [];
+ foreach ( (array) ( $record['severity_counts'] ?? [] ) as $severity => $count ) {
+ if ( $count > 0 ) {
+ $severity_summary[] = "{$severity}: {$count}";
+ }
+ }
+ if ( $severity_summary ) {
+ $detail[] = 'Severity: ' . implode( ', ', $severity_summary );
}
- $title = $plugin->post_title;
- if ( 'closed' === $plugin->post_status ) {
- $title .= ' (closed)';
+ self::send_slack_alert(
+ $plugin,
+ 'A security scan detected findings in *%s*',
+ $record['version'],
+ $record['release_ref'],
+ $detail,
+ $record['report_url']
+ );
+ }
+
+ /**
+ * Alert Slack about a high-risk verdict. Always sends: a high score needs a human either way.
+ *
+ * @param \WP_Post $plugin The plugin post.
+ * @param array $record 'version', 'release_ref', 'risk_score', 'report_url', and 'held'
+ * (whether the release was held).
+ */
+ protected static function notify_slack_blocked( $plugin, $record ) {
+ if ( ! empty( $record['held'] ) ) {
+ $headline = 'A security scan *blocked* a release of *%s*';
+ $status = 'Held out of the update API until a reviewer force-releases it.';
+ } else {
+ $headline = 'A security scan flagged a release of *%s*';
+ $status = 'Not held automatically. Review the version and block it from the plugin page if warranted.';
}
- $body = sprintf(
- "Gandalf scan detected findings in *%s*\n%s\nVersion: %s (%s)\nFindings: %d\n",
- $title,
- $install_line,
+ self::send_slack_alert(
+ $plugin,
+ $headline,
$record['version'],
$record['release_ref'],
- $record['findings_count']
+ [
+ sprintf( 'Risk score: %s (flags at %s)', $record['risk_score'], self::RISK_SCORE_BLOCK_THRESHOLD ),
+ $status,
+ ],
+ $record['report_url']
);
+ }
- if ( ! empty( $record['severity_counts'] ) ) {
- $severity_summary = [];
- foreach ( $record['severity_counts'] as $severity => $count ) {
- if ( $count > 0 ) {
- $severity_summary[] = "{$severity}: {$count}";
- }
- }
+ /**
+ * Send a plugin-review Slack alert: the shared envelope for the scan notifications — the
+ * plugin title, active-install count, version line, and links.
+ *
+ * @param \WP_Post $plugin The plugin post.
+ * @param string $headline A sprintf format with a single %s for the plugin title.
+ * @param string $version The scanned version.
+ * @param string $release_ref The scanned release ref.
+ * @param string[] $detail Lines describing the result, placed after the version line.
+ * @param string $report_url Link to the scan report, or '' when there isn't one.
+ */
+ private static function send_slack_alert( $plugin, $headline, $version, $release_ref, $detail, $report_url ) {
+ if ( ! defined( 'PLUGIN_REVIEW_ALERT_SLACK_CHANNEL' ) || ! function_exists( 'slack_dm' ) ) {
+ return;
+ }
- if ( $severity_summary ) {
- $body .= 'Severity: ' . implode( ', ', $severity_summary ) . "\n";
- }
+ $title = $plugin->post_title;
+ if ( 'closed' === $plugin->post_status ) {
+ $title .= ' (closed)';
+ }
+
+ $active_installs = (int) get_post_meta( $plugin->ID, 'active_installs', true );
+ $install_line = sprintf( '%s+ active installs', number_format_i18n( $active_installs ) );
+ if ( $active_installs >= 10000 ) {
+ $install_line = ":warning: {$install_line}";
}
+ $body = sprintf( $headline, $title ) . "\n";
+ $body .= $install_line . "\n";
+ $body .= sprintf( "Version: %s (%s)\n", $version, $release_ref );
+ $body .= implode( "\n", $detail ) . "\n";
$body .= sprintf( "Details: https://wordpress.org/plugins/wp-admin/post.php?post=%s&action=edit\n", $plugin->ID );
$body .= sprintf( "Plugin: https://wordpress.org/plugins/%s/\n", $plugin->post_name );
- $body .= sprintf( "Report: %s\n", $record['report_url'] );
+ if ( ! empty( $report_url ) ) {
+ $body .= sprintf( "Report: %s\n", $report_url );
+ }
slack_dm( $body, PLUGIN_REVIEW_ALERT_SLACK_CHANNEL, true );
}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml b/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml
index dfcbdaedc0..607da030f6 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml
@@ -7,6 +7,7 @@
tests/
tests/bootstrap.php
+ tests/Gandalf_Callback_Test_Case.php
tests/wporg-url-schemes.php
tests/wporg-plugin-api.php
tests/wporg-plugin-api-performance.php
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Callback_Test_Case.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Callback_Test_Case.php
new file mode 100644
index 0000000000..eae4e72155
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Callback_Test_Case.php
@@ -0,0 +1,299 @@
+ 'plugin',
+ 'post_name' => static::SLUG,
+ 'post_title' => 'Gandalf Callback Test',
+ 'post_status' => 'publish',
+ 'post_modified' => current_time( 'mysql' ),
+ 'post_modified_gmt' => current_time( 'mysql', 1 ),
+ ),
+ true
+ );
+
+ $this->assertNotInstanceOf( WP_Error::class, $plugin_id );
+
+ $this->plugin = get_post( $plugin_id );
+
+ update_post_meta( $plugin_id, 'version', static::NEW_VERSION );
+ update_post_meta( $plugin_id, 'stable_tag', static::NEW_VERSION );
+ update_post_meta( $plugin_id, 'header_name', 'Gandalf Callback Test' );
+ update_post_meta( $plugin_id, 'header_author', 'WordPress' );
+ update_post_meta( $plugin_id, 'version_date', current_time( 'mysql', 1 ) );
+
+ update_post_meta( $plugin_id, 'releases', array( $this->release() ) );
+
+ $this->set_pending_scan();
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it.
+ $wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}update_source`" );
+ $this->serve( static::SERVED_VERSION );
+ }
+
+ /**
+ * Remove the plugin, its meta, any audit-log notes, and any deferred cron event.
+ */
+ protected function tearDown(): void {
+ wp_clear_scheduled_hook( 'release_to_update_api:' . static::SLUG );
+
+ foreach ( get_comments( array( 'post_id' => $this->plugin->ID ) ) as $note ) {
+ wp_delete_comment( $note->comment_ID, true );
+ }
+
+ wp_delete_post( $this->plugin->ID, true );
+
+ parent::tearDown();
+ }
+
+ /**
+ * The release row seeded onto the plugin: NEW_VERSION, confirmed, inside its delay.
+ *
+ * @param array $overrides Values to override on the default release.
+ * @return array
+ */
+ protected function release( $overrides = array() ) {
+ return array_merge(
+ array(
+ 'date' => time(),
+ 'tag' => static::NEW_VERSION,
+ 'version' => static::NEW_VERSION,
+ 'zips_built' => true,
+ 'confirmations' => array(),
+ 'confirmed' => true,
+ 'confirmations_required' => 0,
+ 'committer' => array(),
+ 'revision' => array(),
+ 'release_delay' => static::DELAY,
+ ),
+ $overrides
+ );
+ }
+
+ /**
+ * Record the in-flight scan the callback is correlated against.
+ */
+ protected function set_pending_scan() {
+ update_post_meta(
+ $this->plugin->ID,
+ Plugin_Scan_Gandalf::PENDING_META_KEY,
+ array(
+ static::SCAN_ID => array(
+ 'version' => static::NEW_VERSION,
+ 'release_ref' => static::NEW_VERSION,
+ 'requested_at' => time(),
+ ),
+ )
+ );
+ }
+
+ /**
+ * A well-formed completed-scan body, as Gandalf would send it. The scanned version has no
+ * findings; a verdict field like risk_score is added per-test via $overrides.
+ *
+ * @param array $overrides Values to override.
+ * @return array
+ */
+ protected function callback_body( $overrides = array() ) {
+ return array_merge(
+ array(
+ 'scan_id' => static::SCAN_ID,
+ 'status' => 'completed',
+ 'version' => static::NEW_VERSION,
+ 'release_ref' => static::NEW_VERSION,
+ 'findings_count' => 0,
+ 'severity_counts' => array(),
+ 'verdict_hash' => 'abc123',
+ 'report_url' => 'https://gandalf.wordpress.org/report/abc123',
+ ),
+ $overrides
+ );
+ }
+
+ /**
+ * POST a callback to the route, as Gandalf would.
+ *
+ * @param array $body The callback body.
+ * @param string|null $secret The bearer secret, or null to send no Authorization header.
+ * @return \WP_REST_Response
+ */
+ protected function post_callback( $body, $secret = null ) {
+ if ( null === $secret ) {
+ $secret = static::SECRET;
+ }
+
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/plugin/' . static::SLUG . '/gandalf-scan' );
+
+ if ( false !== $secret ) {
+ $request->set_header( 'authorization', 'Bearer ' . $secret );
+ }
+
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( $body ) );
+
+ return rest_do_request( $request );
+ }
+
+ /**
+ * Put a version into `update_source`, standing in for the currently-served release.
+ *
+ * @param string $version The version to serve.
+ */
+ protected function serve( $version ) {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it.
+ $wpdb->insert(
+ $wpdb->prefix . 'update_source',
+ array(
+ 'plugin_id' => $this->plugin->ID,
+ 'plugin_slug' => static::SLUG,
+ 'available' => 1,
+ 'version' => $version,
+ 'last_updated' => current_time( 'mysql' ),
+ )
+ );
+ }
+
+ /**
+ * The version currently served from `update_source`.
+ *
+ * @return string|null
+ */
+ protected function get_served_version() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress, and a cached read would defeat the assertion.
+ return $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT `version` FROM `{$wpdb->prefix}update_source` WHERE `plugin_slug` = %s",
+ static::SLUG
+ )
+ );
+ }
+
+ /**
+ * The pending scans still awaiting a callback.
+ *
+ * @return array
+ */
+ protected function get_pending_scans() {
+ $pending = get_post_meta( $this->plugin->ID, Plugin_Scan_Gandalf::PENDING_META_KEY, true );
+
+ return $pending ? $pending : array();
+ }
+
+ /**
+ * A callback that authenticated and matched its pending scan is processed and no longer in flight.
+ */
+ public function test_a_processed_callback_clears_the_pending_scan() {
+ $response = $this->post_callback( $this->callback_body() );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( array(), $this->get_pending_scans() );
+ }
+
+ /**
+ * The route is authenticated: a callback without the shared secret changes nothing.
+ */
+ public function test_a_callback_without_the_shared_secret_is_rejected() {
+ $response = $this->post_callback( $this->callback_body(), false );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( static::SERVED_VERSION, $this->get_served_version() );
+ }
+
+ /**
+ * The secret is compared, not merely required.
+ */
+ public function test_a_callback_with_the_wrong_shared_secret_is_rejected() {
+ $response = $this->post_callback( $this->callback_body(), 'not-the-secret' );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( static::SERVED_VERSION, $this->get_served_version() );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Callback_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Callback_Test.php
new file mode 100644
index 0000000000..94eb9dd1a3
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Callback_Test.php
@@ -0,0 +1,125 @@
+plugin->ID, 'releases', true ) as $release ) {
+ if ( static::NEW_VERSION === $release['tag'] ) {
+ return $release['release_block'] ?? null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * The headline behaviour: Gandalf reports a blocking risk score over the API, and the
+ * version it scanned is held back — the previous one keeps being served — and recorded.
+ */
+ public function test_a_blocking_score_over_the_api_holds_the_version() {
+ $this->markTestSkipped( 'Auto-blocking on a risk score is disabled; see Plugin_Scan_Gandalf::handle_callback().' );
+
+ $response = $this->post_callback( $this->callback_body( array( 'risk_score' => Plugin_Scan_Gandalf::RISK_SCORE_BLOCK_THRESHOLD ) ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( static::SERVED_VERSION, $this->get_served_version() );
+ $this->assertSame( (float) Plugin_Scan_Gandalf::RISK_SCORE_BLOCK_THRESHOLD, $this->get_release_block()['risk_score'] );
+ $this->assertSame( static::SCAN_ID, $this->get_release_block()['scan_id'] );
+ }
+
+ /**
+ * The block outlasts the cooldown: once the delay elapses, the reconciliation run that
+ * would normally serve the new version still leaves the old one in place.
+ */
+ public function test_a_held_version_is_not_served_when_its_delay_later_elapses() {
+ $this->markTestSkipped( 'Auto-blocking on a risk score is disabled; see Plugin_Scan_Gandalf::handle_callback().' );
+
+ $this->post_callback( $this->callback_body( array( 'risk_score' => 9 ) ) );
+
+ // Age the commit past the delay; without the block this run would serve NEW_VERSION.
+ update_post_meta( $this->plugin->ID, 'version_date', gmdate( 'Y-m-d H:i:s', time() - ( static::DELAY * 2 ) ) );
+ API_Update_Updater::update_single_plugin( static::SLUG );
+
+ $this->assertSame( static::SERVED_VERSION, $this->get_served_version() );
+ }
+
+ /**
+ * A score below the threshold is not a block; the release is left to its normal delay.
+ */
+ public function test_a_score_below_the_threshold_over_the_api_does_not_block() {
+ $response = $this->post_callback( $this->callback_body( array( 'risk_score' => Plugin_Scan_Gandalf::RISK_SCORE_BLOCK_THRESHOLD - 1 ) ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * An absent risk_score — every scan until Gandalf sends one — never blocks.
+ */
+ public function test_an_absent_risk_score_over_the_api_does_not_block() {
+ $response = $this->post_callback( $this->callback_body() );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * A blocking score for a version that is already live can't un-ship it: `update_source`
+ * is left untouched and nothing is held.
+ */
+ public function test_a_blocking_score_for_an_already_served_version_is_left_untouched() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it.
+ $wpdb->update(
+ $wpdb->prefix . 'update_source',
+ array( 'version' => static::NEW_VERSION ),
+ array( 'plugin_slug' => static::SLUG )
+ );
+
+ $response = $this->post_callback( $this->callback_body( array( 'risk_score' => 9 ) ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( static::NEW_VERSION, $this->get_served_version() );
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * A reviewer force-release closes the loop: a version blocked over the API is served, and
+ * the block cleared.
+ */
+ public function test_force_release_after_an_api_block_serves_the_version() {
+ $this->markTestSkipped( 'Auto-blocking on a risk score is disabled; see Plugin_Scan_Gandalf::handle_callback().' );
+
+ $this->post_callback( $this->callback_body( array( 'risk_score' => 9 ) ) );
+
+ $result = API_Update_Updater::force_release( static::SLUG, 'Reviewed the scan; false positive.' );
+
+ $this->assertTrue( $result );
+ $this->assertNull( $this->get_release_block() );
+ $this->assertSame( static::NEW_VERSION, $this->get_served_version() );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Test.php
new file mode 100644
index 0000000000..945244be5e
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Risk_Score_Block_Test.php
@@ -0,0 +1,394 @@
+ 'plugin',
+ 'post_name' => self::SLUG,
+ 'post_title' => 'Gandalf Block Test',
+ 'post_status' => 'publish',
+ 'post_modified' => current_time( 'mysql' ),
+ 'post_modified_gmt' => current_time( 'mysql', 1 ),
+ ),
+ true
+ );
+
+ $this->assertNotInstanceOf( WP_Error::class, $plugin_id );
+
+ $this->plugin = get_post( $plugin_id );
+
+ update_post_meta( $plugin_id, 'version', self::NEW_VERSION );
+ update_post_meta( $plugin_id, 'stable_tag', self::NEW_VERSION );
+ update_post_meta( $plugin_id, 'header_name', 'Gandalf Block Test' );
+ update_post_meta( $plugin_id, 'header_author', 'WordPress' );
+ update_post_meta( $plugin_id, 'version_date', gmdate( 'Y-m-d H:i:s', time() ) );
+
+ $this->set_releases( array( $this->release() ) );
+ $this->set_pending_scan( self::NEW_VERSION );
+
+ $wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}update_source`" );
+ $this->serve( self::SERVED_VERSION );
+ }
+
+ /**
+ * Remove the plugin, its meta, any audit-log notes, and any deferred cron event. There's
+ * no transaction to roll back without WP_UnitTestCase, so state would otherwise leak.
+ */
+ protected function tearDown(): void {
+ wp_clear_scheduled_hook( 'release_to_update_api:' . self::SLUG );
+
+ foreach ( get_comments( array( 'post_id' => $this->plugin->ID ) ) as $note ) {
+ wp_delete_comment( $note->comment_ID, true );
+ }
+
+ wp_delete_post( $this->plugin->ID, true );
+
+ parent::tearDown();
+ }
+
+ /**
+ * A complete release row. get_releases() reads keys beyond the ones under test.
+ *
+ * @param array $overrides Values to override on the default release.
+ * @return array
+ */
+ protected function release( $overrides = array() ) {
+ return array_merge(
+ array(
+ 'date' => time(),
+ 'tag' => self::NEW_VERSION,
+ 'version' => self::NEW_VERSION,
+ 'zips_built' => true,
+ 'confirmations' => array(),
+ 'confirmed' => true,
+ 'confirmations_required' => 0,
+ 'committer' => array(),
+ 'revision' => array(),
+ 'release_delay' => self::DELAY,
+ ),
+ $overrides
+ );
+ }
+
+ /**
+ * Seed the releases meta directly: get_releases() otherwise falls back to
+ * prefill_releases_meta(), which reaches out to SVN.
+ *
+ * @param array $releases The releases to store.
+ */
+ protected function set_releases( $releases ) {
+ update_post_meta( $this->plugin->ID, 'releases', $releases );
+ }
+
+ /**
+ * Record a pending Gandalf scan so handle_callback() recognizes the callback.
+ *
+ * @param string $version The version being scanned.
+ */
+ protected function set_pending_scan( $version ) {
+ update_post_meta(
+ $this->plugin->ID,
+ Plugin_Scan_Gandalf::PENDING_META_KEY,
+ array(
+ self::SCAN_ID => array(
+ 'version' => $version,
+ 'release_ref' => $version,
+ 'requested_at' => time(),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Build a completed-scan callback payload for version 2.0.
+ *
+ * @param array $overrides Values to override on the default payload.
+ * @return array
+ */
+ protected function callback_data( $overrides = array() ) {
+ return array_merge(
+ array(
+ 'scan_id' => self::SCAN_ID,
+ 'version' => self::NEW_VERSION,
+ 'release_ref' => self::NEW_VERSION,
+ 'status' => 'completed',
+ 'findings_count' => 0,
+ 'severity_counts' => array(),
+ 'verdict_hash' => 'hash',
+ 'report_url' => 'https://gandalf.wordpress.org/report/abc',
+ ),
+ $overrides
+ );
+ }
+
+ /**
+ * Put a version into `update_source`, standing in for the currently-served release.
+ *
+ * @param string $version The version to serve.
+ */
+ protected function serve( $version ) {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it.
+ $wpdb->insert(
+ $wpdb->prefix . 'update_source',
+ array(
+ 'plugin_id' => $this->plugin->ID,
+ 'plugin_slug' => self::SLUG,
+ 'available' => 1,
+ 'version' => $version,
+ 'last_updated' => current_time( 'mysql' ),
+ )
+ );
+ }
+
+ /**
+ * The version currently served from `update_source`.
+ *
+ * @return string|null
+ */
+ protected function get_served_version() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress, and a cached read would defeat the assertion.
+ return $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT `version` FROM `{$wpdb->prefix}update_source` WHERE `plugin_slug` = %s",
+ self::SLUG
+ )
+ );
+ }
+
+ /**
+ * The block recorded against the current release, if any.
+ *
+ * @param string $tag The release tag.
+ * @return array|null The `release_block` value, or null when the release isn't held.
+ */
+ protected function get_release_block( $tag = self::NEW_VERSION ) {
+ foreach ( (array) get_post_meta( $this->plugin->ID, 'releases', true ) as $release ) {
+ if ( $tag === $release['tag'] ) {
+ return $release['release_block'] ?? null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * A score at the threshold holds the version: the previous one keeps being served,
+ * the block is recorded, and any deferred serve is cancelled.
+ */
+ public function test_a_blocking_score_holds_the_version() {
+ $this->markTestSkipped( 'Auto-blocking on a risk score is disabled; see Plugin_Scan_Gandalf::handle_callback().' );
+
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) );
+
+ $this->assertSame( self::SERVED_VERSION, $this->get_served_version() );
+ $this->assertSame( 8.0, $this->get_release_block()['risk_score'] );
+ $this->assertSame( self::SCAN_ID, $this->get_release_block()['scan_id'] );
+ $this->assertFalse( wp_next_scheduled( 'release_to_update_api:' . self::SLUG ) );
+ }
+
+ /**
+ * A score above the threshold blocks just the same.
+ */
+ public function test_a_score_above_the_threshold_holds_the_version() {
+ $this->markTestSkipped( 'Auto-blocking on a risk score is disabled; see Plugin_Scan_Gandalf::handle_callback().' );
+
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 9.5 ) ) );
+
+ $this->assertSame( self::SERVED_VERSION, $this->get_served_version() );
+ $this->assertNotNull( $this->get_release_block() );
+ }
+
+ /**
+ * A score below the threshold is not a block; the release follows its normal delay.
+ */
+ public function test_a_score_below_the_threshold_does_not_block() {
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 7.9 ) ) );
+
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * An absent risk_score — every scan until Gandalf sends one — never blocks.
+ */
+ public function test_an_absent_risk_score_does_not_block() {
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data() );
+
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * A non-numeric risk_score is ignored rather than read as a block.
+ */
+ public function test_a_non_numeric_risk_score_does_not_block() {
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 'high' ) ) );
+
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * A reviewer force-release overrides the block: the held version is served and the block cleared.
+ */
+ public function test_force_release_clears_the_block_and_serves() {
+ $this->markTestSkipped( 'Auto-blocking on a risk score is disabled; see Plugin_Scan_Gandalf::handle_callback().' );
+
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) );
+
+ $result = API_Update_Updater::force_release( self::SLUG, 'Reviewed the scan; false positive.' );
+
+ $this->assertTrue( $result );
+ $this->assertNull( $this->get_release_block() );
+ $this->assertSame( self::NEW_VERSION, $this->get_served_version() );
+ }
+
+ /**
+ * The override is recorded, naming the block it bypassed.
+ */
+ public function test_force_release_records_the_override_in_the_audit_log() {
+ $this->markTestSkipped( 'Auto-blocking on a risk score is disabled; see Plugin_Scan_Gandalf::handle_callback().' );
+
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) );
+
+ API_Update_Updater::force_release( self::SLUG, 'Reviewed the scan; false positive.' );
+
+ $notes = get_comments(
+ array(
+ 'post_id' => $this->plugin->ID,
+ 'type' => 'internal-note',
+ )
+ );
+
+ $override = array_filter(
+ $notes,
+ function ( $note ) {
+ return false !== strpos( $note->comment_content, 'overriding the security-scan block' );
+ }
+ );
+
+ $this->assertCount( 1, $override );
+ }
+
+ /**
+ * When the version is already live — the delay elapsed before the verdict arrived — a
+ * blocking score can't un-ship it: `update_source` is left untouched and nothing is held.
+ */
+ public function test_a_blocking_score_leaves_an_already_served_version_untouched() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it.
+ $wpdb->update(
+ $wpdb->prefix . 'update_source',
+ array( 'version' => self::NEW_VERSION ),
+ array( 'plugin_slug' => self::SLUG )
+ );
+
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) );
+
+ $this->assertSame( self::NEW_VERSION, $this->get_served_version() );
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * A verdict on a version that a newer commit has already superseded does not hold anything.
+ */
+ public function test_a_blocking_score_for_a_superseded_version_does_not_block() {
+ update_post_meta( $this->plugin->ID, 'version', '3.0' );
+
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) );
+
+ $this->assertNull( $this->get_release_block() );
+ $this->assertSame( self::SERVED_VERSION, $this->get_served_version() );
+ }
+
+ /**
+ * With no delay captured at release creation, the version was served at import, so there's
+ * nothing left to hold.
+ */
+ public function test_a_blocking_score_does_not_block_a_release_that_had_no_delay() {
+ $this->set_releases( array( $this->release( array( 'release_delay' => 0 ) ) ) );
+
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) );
+
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * A held version stays held across the reconciliation cron that would otherwise serve it.
+ */
+ public function test_a_held_version_is_not_served_by_a_later_update_run() {
+ $this->markTestSkipped( 'Auto-blocking on a risk score is disabled; see Plugin_Scan_Gandalf::handle_callback().' );
+
+ Plugin_Scan_Gandalf::handle_callback( $this->plugin, $this->callback_data( array( 'risk_score' => 8 ) ) );
+
+ // The delay has since elapsed; without the block this would serve 2.0.
+ update_post_meta( $this->plugin->ID, 'version_date', gmdate( 'Y-m-d H:i:s', time() - ( self::DELAY * 2 ) ) );
+
+ API_Update_Updater::update_single_plugin( self::SLUG );
+
+ $this->assertSame( self::SERVED_VERSION, $this->get_served_version() );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Reviewer_Release_Block_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Reviewer_Release_Block_Test.php
new file mode 100644
index 0000000000..e726b9ff40
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Reviewer_Release_Block_Test.php
@@ -0,0 +1,326 @@
+ 'plugin',
+ 'post_name' => self::SLUG,
+ 'post_title' => 'Reviewer Block Test',
+ 'post_status' => 'publish',
+ 'post_modified' => current_time( 'mysql' ),
+ 'post_modified_gmt' => current_time( 'mysql', 1 ),
+ ),
+ true
+ );
+
+ $this->assertNotInstanceOf( WP_Error::class, $plugin_id );
+
+ $this->plugin = get_post( $plugin_id );
+
+ update_post_meta( $plugin_id, 'version', self::NEW_VERSION );
+ update_post_meta( $plugin_id, 'stable_tag', self::NEW_VERSION );
+ update_post_meta( $plugin_id, 'header_name', 'Reviewer Block Test' );
+ update_post_meta( $plugin_id, 'header_author', 'WordPress' );
+ update_post_meta( $plugin_id, 'version_date', gmdate( 'Y-m-d H:i:s', time() ) );
+
+ $this->set_releases( array( $this->release() ) );
+
+ $reviewer_id = wp_insert_user(
+ array(
+ 'user_login' => 'reviewer-block-user',
+ 'user_pass' => 'password',
+ 'user_email' => 'reviewer-block-user@example.org',
+ )
+ );
+ $this->assertNotInstanceOf( WP_Error::class, $reviewer_id );
+ $this->reviewer = get_user_by( 'id', $reviewer_id );
+
+ $wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}update_source`" );
+ $this->serve( self::SERVED_VERSION );
+ }
+
+ /**
+ * Remove the plugin, its meta, the reviewer, any audit-log notes, and any deferred cron
+ * event. There's no transaction to roll back without WP_UnitTestCase, so state would leak.
+ */
+ protected function tearDown(): void {
+ wp_clear_scheduled_hook( 'release_to_update_api:' . self::SLUG );
+
+ foreach ( get_comments( array( 'post_id' => $this->plugin->ID ) ) as $note ) {
+ wp_delete_comment( $note->comment_ID, true );
+ }
+
+ wp_delete_post( $this->plugin->ID, true );
+ wp_delete_user( $this->reviewer->ID );
+
+ parent::tearDown();
+ }
+
+ /**
+ * A complete release row. get_releases() reads keys beyond the ones under test.
+ *
+ * @param array $overrides Values to override on the default release.
+ * @return array
+ */
+ protected function release( $overrides = array() ) {
+ return array_merge(
+ array(
+ 'date' => time(),
+ 'tag' => self::NEW_VERSION,
+ 'version' => self::NEW_VERSION,
+ 'zips_built' => true,
+ 'confirmations' => array(),
+ 'confirmed' => true,
+ 'confirmations_required' => 0,
+ 'committer' => array(),
+ 'revision' => array(),
+ 'release_delay' => self::DELAY,
+ ),
+ $overrides
+ );
+ }
+
+ /**
+ * Seed the releases meta directly: get_releases() otherwise falls back to
+ * prefill_releases_meta(), which reaches out to SVN.
+ *
+ * @param array $releases The releases to store.
+ */
+ protected function set_releases( $releases ) {
+ update_post_meta( $this->plugin->ID, 'releases', $releases );
+ }
+
+ /**
+ * Put a version into `update_source`, standing in for the currently-served release.
+ *
+ * @param string $version The version to serve.
+ */
+ protected function serve( $version ) {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it.
+ $wpdb->insert(
+ $wpdb->prefix . 'update_source',
+ array(
+ 'plugin_id' => $this->plugin->ID,
+ 'plugin_slug' => self::SLUG,
+ 'available' => 1,
+ 'version' => $version,
+ 'last_updated' => current_time( 'mysql' ),
+ )
+ );
+ }
+
+ /**
+ * The version currently served from `update_source`.
+ *
+ * @return string|null
+ */
+ protected function get_served_version() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress, and a cached read would defeat the assertion.
+ return $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT `version` FROM `{$wpdb->prefix}update_source` WHERE `plugin_slug` = %s",
+ self::SLUG
+ )
+ );
+ }
+
+ /**
+ * The block recorded against the current release, if any.
+ *
+ * @param string $tag The release tag.
+ * @return array|null The `release_block` value, or null when the release isn't held.
+ */
+ protected function get_release_block( $tag = self::NEW_VERSION ) {
+ foreach ( (array) get_post_meta( $this->plugin->ID, 'releases', true ) as $release ) {
+ if ( $tag === $release['tag'] ) {
+ return $release['release_block'] ?? null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * The block payload the Controls metabox sends for a reviewer block.
+ *
+ * @return array
+ */
+ protected function reviewer_block() {
+ return array(
+ 'reason' => 'Suspicious obfuscated code.',
+ 'blocked_by' => $this->reviewer->user_login,
+ );
+ }
+
+ /**
+ * A reviewer block holds the in-cooldown version: the previous one keeps being served, the
+ * block is recorded with the reason and reviewer, and any deferred serve is cancelled.
+ */
+ public function test_a_block_holds_the_in_cooldown_version() {
+ $result = API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $this->assertTrue( $result );
+ $this->assertSame( self::SERVED_VERSION, $this->get_served_version() );
+ $this->assertSame( 'Suspicious obfuscated code.', $this->get_release_block()['reason'] );
+ $this->assertSame( $this->reviewer->user_login, $this->get_release_block()['blocked_by'] );
+ $this->assertFalse( wp_next_scheduled( 'release_to_update_api:' . self::SLUG ) );
+ }
+
+ /**
+ * The block is recorded in the audit log with the supplied reason.
+ */
+ public function test_a_block_records_an_audit_note() {
+ API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $notes = get_comments(
+ array(
+ 'post_id' => $this->plugin->ID,
+ 'type' => 'internal-note',
+ )
+ );
+
+ $block_notes = array_filter(
+ $notes,
+ function ( $note ) {
+ return false !== strpos( $note->comment_content, 'Blocked version 2.0 from being served' );
+ }
+ );
+
+ $this->assertCount( 1, $block_notes );
+ }
+
+ /**
+ * A force-release lifts a reviewer block: the held version is served and the block cleared.
+ */
+ public function test_force_release_clears_a_reviewer_block_and_serves() {
+ API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $result = API_Update_Updater::force_release( self::SLUG, 'Reviewed with the author; resolved.' );
+
+ $this->assertTrue( $result );
+ $this->assertNull( $this->get_release_block() );
+ $this->assertSame( self::NEW_VERSION, $this->get_served_version() );
+ }
+
+ /**
+ * The force-release over a reviewer block is recorded, without a risk score to name.
+ */
+ public function test_force_release_over_a_reviewer_block_records_the_override() {
+ API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ API_Update_Updater::force_release( self::SLUG, 'Reviewed with the author; resolved.' );
+
+ $notes = get_comments(
+ array(
+ 'post_id' => $this->plugin->ID,
+ 'type' => 'internal-note',
+ )
+ );
+
+ $override = array_filter(
+ $notes,
+ function ( $note ) {
+ return false !== strpos( $note->comment_content, 'overriding the release block' );
+ }
+ );
+
+ $this->assertCount( 1, $override );
+ }
+
+ /**
+ * A version that's already live can't be un-shipped by a block; `update_source` is left alone.
+ */
+ public function test_a_block_leaves_an_already_served_version_untouched() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it.
+ $wpdb->update(
+ $wpdb->prefix . 'update_source',
+ array( 'version' => self::NEW_VERSION ),
+ array( 'plugin_slug' => self::SLUG )
+ );
+
+ $result = API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $this->assertFalse( $result );
+ $this->assertNull( $this->get_release_block() );
+ $this->assertSame( self::NEW_VERSION, $this->get_served_version() );
+ }
+
+ /**
+ * With no release row for the current version there's nothing to hold, so the block is a no-op.
+ */
+ public function test_a_block_without_a_release_does_nothing() {
+ update_post_meta( $this->plugin->ID, 'version', '3.0' );
+
+ $result = API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $this->assertFalse( $result );
+ $this->assertSame( self::SERVED_VERSION, $this->get_served_version() );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php
index 633d06519c..d58a914417 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php
@@ -52,3 +52,8 @@ function manually_load_plugin() {
// Start up the WP testing environment.
require $_tests_dir . '/includes/bootstrap.php';
+
+// Load shared abstract test-case bases; they're excluded from the suite but subclasses need them.
+foreach ( glob( __DIR__ . '/*_Test_Case.php' ) as $test_case_base ) {
+ require_once $test_case_base;
+}