diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 447c8ac037..e78e4f9d12 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -79,6 +79,11 @@
+
+
+ */tests/*
+
+
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/class-base.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/class-base.php
index 4e88f5fc15..4b407da6b4 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/class-base.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/class-base.php
@@ -49,6 +49,22 @@ function validate_plugin_slug_callback( $value ) {
return is_string( $value ) && $value && Plugin_Directory::get_plugin_post( $value );
}
+ /**
+ * A sanitization callback for REST API locale parameters.
+ *
+ * Restricts the value to the characters present in WordPress locale slugs.
+ *
+ * @param string $value The requested locale.
+ * @return string The sanitized locale.
+ */
+ public function sanitize_locale_callback( $value ) {
+ if ( ! is_scalar( $value ) ) {
+ return '';
+ }
+
+ return preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $value );
+ }
+
/**
* A Permission Check callback which validates the request against the internal api-call token.
*
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php
index c9fce4c5a9..03723837b9 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-gandalf-scan.php
@@ -30,10 +30,48 @@ public function __construct() {
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'scan_callback' ],
+
+ /*
+ * Fields are typed but not required, and tolerate nulls, so
+ * that malformed callbacks reach the callback and are recorded
+ * on the plugin instead of being rejected at the REST layer.
+ */
'args' => [
- 'plugin_slug' => [
+ 'plugin_slug' => [
'validate_callback' => [ $this, 'validate_plugin_slug_callback' ],
],
+ 'scan_id' => [
+ 'type' => 'string',
+ ],
+ 'version' => [
+ 'type' => 'string',
+ ],
+ 'release_ref' => [
+ 'type' => 'string',
+ ],
+ 'status' => [
+ 'type' => 'string',
+ ],
+ 'findings_count' => [
+ 'type' => [ 'integer', 'null' ],
+ ],
+ 'severity_counts' => [
+ 'type' => [ 'object', 'null' ],
+ ],
+ 'verdict_hash' => [
+ 'type' => [ 'string', 'null' ],
+ ],
+ 'report_url' => [
+ 'type' => [ 'string', 'null' ],
+ 'format' => 'uri',
+ ],
+ 'error' => [
+ 'type' => [ 'object', 'null' ],
+ 'properties' => [
+ 'kind' => [ 'type' => 'string' ],
+ 'message' => [ 'type' => 'string' ],
+ ],
+ ],
],
'permission_callback' => function ( $request ) {
return $this->permission_check_api_bearer( $request, 'WP_GANDALF_SCAN_SHARED_SECRET' );
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-internal-stats.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-internal-stats.php
index 59616c9513..bda12fb24d 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-internal-stats.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-internal-stats.php
@@ -23,6 +23,13 @@ function __construct() {
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'bulk_update_stats' ),
'permission_callback' => array( $this, 'permission_check_internal_api_bearer' ),
+ 'args' => array(
+ // Entries are unconstrained so one malformed plugin cannot reject the batch.
+ 'plugins' => array(
+ 'type' => 'object',
+ 'required' => true,
+ ),
+ ),
) );
}
@@ -46,6 +53,10 @@ function bulk_update_stats( $request ) {
$data = $request['plugins'];
foreach ( $data as $plugin_slug => $stats ) {
+ if ( ! is_array( $stats ) ) {
+ continue;
+ }
+
$plugin = Plugin_Directory::get_plugin_post( $plugin_slug );
if ( ! $plugin ) {
continue;
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-blueprint.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-blueprint.php
index 4b7a315976..f56b98f1b4 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-blueprint.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-blueprint.php
@@ -23,6 +23,19 @@ public function __construct() {
'plugin_slug' => array(
'validate_callback' => array( $this, 'validate_plugin_slug_callback' ),
),
+ 'zip_hash' => array(
+ 'type' => 'string',
+ ),
+ 'url_hash' => array(
+ 'type' => 'string',
+ ),
+ 'type' => array(
+ 'type' => 'string',
+ ),
+ 'lang' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
)
) );
}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-committers.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-committers.php
index ffa083c72f..988cdbda9f 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-committers.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-committers.php
@@ -19,6 +19,12 @@ class Plugin_Committers extends Base {
function __construct() {
register_rest_route( 'plugins/v1', '/plugin/(?P[^/]+)/committers/?', array(
+ 'args' => array(
+ 'plugin_slug' => array(
+ 'validate_callback' => array( $this, 'validate_plugin_slug_callback' ),
+ 'required' => true,
+ ),
+ ),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'list_committers' ),
@@ -28,12 +34,6 @@ function __construct() {
Plugin_Directory::get_plugin_post( $request['plugin_slug'] )
);
},
- 'args' => array(
- 'plugin_slug' => array(
- 'validate_callback' => array( $this, 'validate_plugin_slug_callback' ),
- 'required' => true,
- ),
- ),
),
array(
'methods' => WP_REST_Server::CREATABLE,
@@ -45,9 +45,10 @@ function __construct() {
);
},
'args' => array(
- 'plugin_slug' => array(
- 'validate_callback' => array( $this, 'validate_plugin_slug_callback' ),
- 'required' => true,
+ // The user_login, user_nicename, or user_email of the user to add.
+ 'committer' => array(
+ 'type' => 'string',
+ 'required' => true,
),
),
),
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-release-confirmation.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-release-confirmation.php
index bb68825e5a..f8af533f98 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-release-confirmation.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-release-confirmation.php
@@ -4,6 +4,7 @@
use WP_REST_Response;
use WordPressdotorg\Plugin_Directory\Plugin_Directory;
use WordPressdotorg\Plugin_Directory\API\Base;
+use WordPressdotorg\Plugin_Directory\Template;
use WordPressdotorg\Plugin_Directory\Tools;
use WordPressdotorg\Plugin_Directory\Jobs\Plugin_Import;
use WordPressdotorg\Plugin_Directory\Email\Release_Confirmation_Enabled as Release_Confirmation_Enabled_Email;
@@ -28,6 +29,10 @@ public function __construct() {
'plugin_slug' => [
'validate_callback' => [ $this, 'validate_plugin_slug_callback' ],
],
+ 'confirmations_required' => [
+ 'type' => 'integer',
+ 'minimum' => 0,
+ ],
],
'permission_callback' => function( $request ) {
$plugin = Plugin_Directory::get_plugin_post( $request['plugin_slug'] );
@@ -45,7 +50,11 @@ public function __construct() {
],
'plugin_tag' => [
'validate_callback' => [ $this, 'validate_plugin_tag_callback' ],
- ]
+ ],
+ 'rollout_strategy' => [
+ 'type' => 'string',
+ 'enum' => array_keys( Template::get_rollout_strategies() ),
+ ],
],
'permission_callback' => [ $this, 'permission_can_access_plugin' ],
] );
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-review.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-review.php
index bfa27ff22a..b7475b5867 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-review.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-review.php
@@ -22,6 +22,15 @@ function __construct() {
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'plugin_review_info' ),
'permission_callback' => array( $this, 'plugin_info_permission_check' ),
+ 'args' => array(
+ 'plugin_id' => array(
+ 'type' => 'integer',
+ ),
+ 'token' => array(
+ 'type' => 'string',
+ 'pattern' => '^[a-f0-9]{32}$',
+ ),
+ ),
) );
}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-self-toggle-preview.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-self-toggle-preview.php
index 9860e3934a..92c4e4b610 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-self-toggle-preview.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-self-toggle-preview.php
@@ -21,6 +21,10 @@ public function __construct() {
'plugin_slug' => [
'validate_callback' => [ $this, 'validate_plugin_slug_callback' ],
],
+ // Whether to dismiss the missing blueprint notice instead of toggling the preview.
+ 'dismiss' => [
+ 'type' => 'boolean',
+ ],
],
'permission_callback' => function( $request ) {
$plugin = Plugin_Directory::get_plugin_post( $request['plugin_slug'] );
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-support-reps.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-support-reps.php
index c6108e85fc..cec4da7bbe 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-support-reps.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-support-reps.php
@@ -19,6 +19,12 @@ class Plugin_Support_Reps extends Base {
function __construct() {
register_rest_route( 'plugins/v1', '/plugin/(?P[^/]+)/support-reps/?', array(
+ 'args' => array(
+ 'plugin_slug' => array(
+ 'validate_callback' => array( $this, 'validate_plugin_slug_callback' ),
+ 'required' => true,
+ ),
+ ),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'list_support_reps' ),
@@ -28,12 +34,6 @@ function __construct() {
Plugin_Directory::get_plugin_post( $request['plugin_slug'] )
);
},
- 'args' => array(
- 'plugin_slug' => array(
- 'validate_callback' => array( $this, 'validate_plugin_slug_callback' ),
- 'required' => true,
- ),
- ),
),
array(
'methods' => WP_REST_Server::CREATABLE,
@@ -45,9 +45,10 @@ function __construct() {
);
},
'args' => array(
- 'plugin_slug' => array(
- 'validate_callback' => array( $this, 'validate_plugin_slug_callback' ),
- 'required' => true,
+ // The user_login, user_nicename, or user_email of the user to add.
+ 'support_rep' => array(
+ 'type' => 'string',
+ 'required' => true,
),
),
),
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-upload.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-upload.php
index ae68456469..a3a2352884 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-upload.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin-upload.php
@@ -26,6 +26,9 @@ function __construct() {
'callback' => array( $this, 'upload' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => [
+ 'ID' => [
+ 'type' => 'integer',
+ ],
'post_name' => [
'type' => 'string',
'required' => false,
@@ -38,6 +41,9 @@ function __construct() {
'callback' => array( $this, 'slug' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => [
+ 'ID' => [
+ 'type' => 'integer',
+ ],
'post_name' => [
'type' => 'string',
'required' => true,
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php
index 8014357ea5..90ff3bda4d 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php
@@ -28,6 +28,10 @@ function __construct() {
'plugin_slug' => array(
'validate_callback' => array( $this, 'validate_plugin_slug_callback' ),
),
+ 'locale' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => array( $this, 'sanitize_locale_callback' ),
+ ),
),
) );
}
@@ -373,7 +377,7 @@ protected function get_user_profile_link( $user ) {
*/
protected function get_plugin_reviews_markup( $plugin_slug ) {
$output = '';
- foreach ( Tools::get_plugin_reviews( $plugin_slug, 10 ) as $review ) {
+ foreach ( Tools::get_plugin_reviews( $plugin_slug, 10 ) ?: array() as $review ) {
$output .= $this->get_plugin_reviews_markup_singular( $review );
}
return $output;
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-query-plugins.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-query-plugins.php
index 9e22834b5b..24c266793d 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-query-plugins.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-query-plugins.php
@@ -30,6 +30,60 @@ function __construct() {
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'query' ),
'permission_callback' => '__return_true',
+
+ /*
+ * api.wordpress.org proxies client input into this route verbatim,
+ * so all parameters coerce rather than reject: custom sanitize
+ * callbacks intentionally replace strict schema validation.
+ */
+ 'args' => array(
+ 'paged' => array(
+ 'type' => 'integer',
+ 'sanitize_callback' => 'absint',
+ ),
+ 'posts_per_page' => array(
+ 'type' => 'integer',
+ 'sanitize_callback' => 'absint',
+ ),
+ 'browse' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'favorites_user' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'plugin_category' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 's' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'author_name' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'installed_plugins' => array(
+ 'type' => array( 'string', 'array' ),
+ 'items' => array( 'type' => 'string' ),
+ 'sanitize_callback' => 'wp_parse_list',
+ ),
+ 'plugin_tags' => array(
+ 'type' => array( 'string', 'array' ),
+ 'items' => array( 'type' => 'string' ),
+ 'sanitize_callback' => 'wp_parse_list',
+ ),
+ 'locale' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => array( $this, 'sanitize_locale_callback' ),
+ ),
+ 'block' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
) );
}
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..5d2d1006cf 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
@@ -160,7 +160,7 @@ public static function dispatch( $plugin, $request_data ) {
* @return true|WP_Error True on success, or an error when the scan is unknown.
*/
public static function handle_callback( $plugin, $data ) {
- $scan_id = $data['scan_id'];
+ $scan_id = $data['scan_id'] ?? '';
$pending = get_post_meta( $plugin->ID, self::PENDING_META_KEY, true ) ?: [];
if ( empty( $pending[ $scan_id ] ) ) {
@@ -171,28 +171,28 @@ public static function handle_callback( $plugin, $data ) {
$pending_record = $pending[ $scan_id ];
- if ( $data['version'] !== $pending_record['version'] || $data['release_ref'] !== $pending_record['release_ref'] ) {
+ if ( ( $data['version'] ?? null ) !== $pending_record['version'] || ( $data['release_ref'] ?? null ) !== $pending_record['release_ref'] ) {
$error = new WP_Error( 'invalid_gandalf_scan', 'Gandalf callback does not match the pending scan.', [ 'status' => WP_Http::BAD_REQUEST ] );
self::record_invalid_callback( $plugin, $error, $scan_id );
return $error;
}
- if ( 'completed' === $data['status'] ) {
- if ( $data['findings_count'] > 0 ) {
+ if ( 'completed' === ( $data['status'] ?? '' ) ) {
+ if ( ( $data['findings_count'] ?? 0 ) > 0 ) {
self::notify_slack(
$plugin,
[
'version' => $pending_record['version'],
'release_ref' => $pending_record['release_ref'],
'findings_count' => $data['findings_count'],
- 'severity_counts' => $data['severity_counts'],
- 'verdict_hash' => $data['verdict_hash'],
- 'report_url' => $data['report_url'],
+ 'severity_counts' => $data['severity_counts'] ?? [],
+ 'verdict_hash' => $data['verdict_hash'] ?? '',
+ 'report_url' => $data['report_url'] ?? '',
]
);
}
} else {
- self::record_last_error( $plugin, $data['error']['kind'], $data['error']['message'], $scan_id );
+ self::record_last_error( $plugin, $data['error']['kind'] ?? 'unknown', $data['error']['message'] ?? '', $scan_id );
}
unset( $pending[ $scan_id ] );
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..54e8261d1c 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/Endpoint_TestCase.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/Endpoint_TestCase.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Endpoint_TestCase.php
new file mode 100644
index 0000000000..d75802c63f
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Endpoint_TestCase.php
@@ -0,0 +1,66 @@
+prefix );
+ }
+ }
+
+ /**
+ * Returns the REST server with all routes registered.
+ *
+ * @return \WP_REST_Server
+ */
+ protected static function server() {
+ return rest_get_server();
+ }
+
+ /**
+ * Creates a plugin post.
+ *
+ * The post_modified fields must be passed explicitly, as the plugin
+ * copies them from the raw postarr via filter_wp_insert_post_data(),
+ * and directory queries INNER JOIN on the _active_installs meta.
+ *
+ * @param string $slug The plugin slug.
+ * @param string $title The plugin title.
+ * @param array $args Optional. Overrides for the post array.
+ * @return int The post ID.
+ */
+ protected static function create_plugin( $slug, $title, $args = array() ) {
+ $defaults = array(
+ 'post_type' => 'plugin',
+ 'post_status' => 'publish',
+ 'post_name' => $slug,
+ 'post_title' => $title,
+ 'post_modified' => current_time( 'mysql' ),
+ 'post_modified_gmt' => current_time( 'mysql', true ),
+ 'meta_input' => array(
+ '_active_installs' => 0,
+ ),
+ );
+
+ return wp_insert_post( wp_parse_args( $args, $defaults ) );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Test.php
new file mode 100644
index 0000000000..2884ba0a0d
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Test.php
@@ -0,0 +1,140 @@
+/gandalf-scan endpoint.
+ *
+ * @package WordPressdotorg\Plugin_Directory\Tests
+ */
+
+use WordPressdotorg\Plugin_Directory\Jobs\Plugin_Scan_Gandalf;
+
+/**
+ * Functional tests for the plugins/v1/plugin//gandalf-scan endpoint.
+ *
+ * @group rest-api
+ */
+class Gandalf_Scan_Test extends Plugin_Directory_Endpoint_TestCase {
+
+ /**
+ * ID of the plugin fixture.
+ *
+ * @var int
+ */
+ protected static $plugin_id;
+
+ /**
+ * Creates the plugin fixture and the shared secret.
+ */
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+
+ if ( ! defined( 'WP_GANDALF_SCAN_SHARED_SECRET' ) ) {
+ define( 'WP_GANDALF_SCAN_SHARED_SECRET', 'gandalf-test-secret' );
+ }
+
+ self::$plugin_id = self::create_plugin( 'gandalf-test-plugin', 'Gandalf Test Plugin' );
+ }
+
+ /**
+ * Deletes the plugin fixture.
+ */
+ public static function tearDownAfterClass(): void {
+ wp_delete_post( self::$plugin_id, true );
+
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * Scan callbacks require the shared secret.
+ */
+ public function test_requires_authentication() {
+ $body = array(
+ 'scan_id' => 'scan-123',
+ 'version' => '1.0',
+ 'release_ref' => 'tags/1.0',
+ 'status' => 'completed',
+ );
+
+ $request = $this->callback_request( $body, false );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'not_authorized', $response->get_data()['code'] );
+ }
+
+ /**
+ * Malformed callbacks reach the handler and are recorded on the plugin
+ * instead of being rejected at the REST layer.
+ */
+ public function test_records_malformed_callbacks() {
+ $request = $this->callback_request( array( 'scan_id' => 'scan-123' ) );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'unknown_gandalf_scan', $response->get_data()['code'] );
+ $this->assertNotEmpty( get_post_meta( self::$plugin_id, Plugin_Scan_Gandalf::LAST_ERROR_META_KEY, true ) );
+ }
+
+ /**
+ * Callbacks padding inapplicable fields with nulls pass the schema.
+ */
+ public function test_accepts_null_padded_callbacks() {
+ $request = $this->callback_request( array(
+ 'scan_id' => 'scan-that-was-never-dispatched',
+ 'version' => '1.0',
+ 'release_ref' => 'tags/1.0',
+ 'status' => 'completed',
+ 'findings_count' => null,
+ 'severity_counts' => null,
+ 'verdict_hash' => null,
+ 'report_url' => null,
+ 'error' => null,
+ ) );
+
+ $response = self::server()->dispatch( $request );
+
+ // Rejected by the handler as an unknown scan, not by the schema.
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'unknown_gandalf_scan', $response->get_data()['code'] );
+ }
+
+ /**
+ * Callbacks for unknown scans are rejected and recorded on the plugin.
+ */
+ public function test_rejects_unknown_scan() {
+ $request = $this->callback_request(
+ array(
+ 'scan_id' => 'scan-that-was-never-dispatched',
+ 'version' => '1.0',
+ 'release_ref' => 'tags/1.0',
+ 'status' => 'completed',
+ )
+ );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'unknown_gandalf_scan', $response->get_data()['code'] );
+ $this->assertNotEmpty( get_post_meta( self::$plugin_id, Plugin_Scan_Gandalf::LAST_ERROR_META_KEY, true ) );
+ }
+
+ /**
+ * Builds a scan callback request with a JSON body.
+ *
+ * @param array $body The callback body.
+ * @param bool $authorized Optional. Whether to include the shared secret. Default true.
+ * @return WP_REST_Request The request.
+ */
+ protected function callback_request( $body, $authorized = true ) {
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/plugin/gandalf-test-plugin/gandalf-scan' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( $body ) );
+
+ if ( $authorized ) {
+ $request->set_header( 'Authorization', 'Bearer ' . WP_GANDALF_SCAN_SHARED_SECRET );
+ }
+
+ return $request;
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Internal_Stats_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Internal_Stats_Test.php
new file mode 100644
index 0000000000..ad2c42969e
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Internal_Stats_Test.php
@@ -0,0 +1,142 @@
+set_param( 'plugins', array( 'stats-test-plugin' => array( 'active_installs' => 100 ) ) );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'not_authorized', $response->get_data()['code'] );
+ }
+
+ /**
+ * Whitelisted stats are written to the plugin, while unknown plugins
+ * and unknown stat names are silently skipped.
+ */
+ public function test_updates_plugin_stats() {
+ $stats = array(
+ 'stats-test-plugin' => array(
+ 'active_installs' => 5000,
+ 'support_threads' => '7',
+ 'unknown_stat' => 'ignored',
+ ),
+ 'not-a-registered-plugin' => array(
+ 'active_installs' => 100,
+ ),
+ );
+
+ $request = $this->authorized_request();
+ $request->set_param( 'plugins', $stats );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $response->get_data() );
+ $this->assertEquals( 5000, get_post_meta( self::$plugin_id, '_active_installs', true ) );
+ $this->assertEquals( 7, get_post_meta( self::$plugin_id, 'support_threads', true ) );
+ $this->assertSame( '', get_post_meta( self::$plugin_id, 'unknown_stat', true ) );
+ }
+
+ /**
+ * A malformed entry is skipped without rejecting the rest of the batch.
+ */
+ public function test_skips_malformed_entries() {
+ $stats = array(
+ 'stats-test-plugin' => array(
+ 'active_installs' => 100,
+ ),
+ 'malformed-entry' => 'not-an-object',
+ );
+
+ $request = $this->authorized_request();
+ $request->set_param( 'plugins', $stats );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $response->get_data() );
+ $this->assertEquals( 100, get_post_meta( self::$plugin_id, '_active_installs', true ) );
+ }
+
+ /**
+ * A scalar plugins parameter is rejected instead of being iterated.
+ */
+ public function test_rejects_malformed_body() {
+ $request = $this->authorized_request();
+ $request->set_param( 'plugins', 'not-an-object' );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
+ }
+
+ /**
+ * A request without stats data is rejected.
+ */
+ public function test_requires_stats_data() {
+ $request = $this->authorized_request();
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_missing_callback_param', $response->get_data()['code'] );
+ }
+
+ /**
+ * Builds an update-stats request carrying the internal bearer token.
+ *
+ * @return WP_REST_Request The authorized request.
+ */
+ protected function authorized_request() {
+ if ( ! defined( 'PLUGIN_API_INTERNAL_BEARER_TOKEN' ) ) {
+ define( 'PLUGIN_API_INTERNAL_BEARER_TOKEN', 'plugin-directory-test-token' );
+ }
+
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/update-stats' );
+ $request->set_header( 'Authorization', 'Bearer ' . PLUGIN_API_INTERNAL_BEARER_TOKEN );
+
+ return $request;
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Blueprint_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Blueprint_Test.php
new file mode 100644
index 0000000000..7599c76e94
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Blueprint_Test.php
@@ -0,0 +1,61 @@
+/blueprint.json endpoint.
+ *
+ * @package WordPressdotorg\Plugin_Directory\Tests
+ */
+
+/**
+ * Functional tests for the plugins/v1/plugin//blueprint.json endpoint.
+ *
+ * @group rest-api
+ */
+class Plugin_Blueprint_Test extends Plugin_Directory_Endpoint_TestCase {
+
+ /**
+ * ID of the published plugin fixture.
+ *
+ * @var int
+ */
+ protected static $plugin_id;
+
+ /**
+ * Creates the plugin fixture.
+ */
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+
+ self::$plugin_id = self::create_plugin( 'blueprint-test-plugin', 'Blueprint Test Plugin' );
+ }
+
+ /**
+ * Deletes the plugin fixture.
+ */
+ public static function tearDownAfterClass(): void {
+ wp_delete_post( self::$plugin_id, true );
+
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * Requests for unknown plugins are rejected as invalid.
+ */
+ public function test_rejects_unknown_plugin() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/plugin/not-a-registered-plugin/blueprint.json' );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
+ }
+
+ /**
+ * A plugin without a stored blueprint returns a 404.
+ */
+ public function test_returns_404_without_blueprint() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/plugin/blueprint-test-plugin/blueprint.json' );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 404, $response->get_status() );
+ $this->assertSame( 'no_blueprint', $response->get_data()['code'] );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Committers_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Committers_Test.php
new file mode 100644
index 0000000000..6dead6abb6
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Committers_Test.php
@@ -0,0 +1,90 @@
+/committers endpoints.
+ *
+ * The committer management capabilities are granted through the plugin's
+ * custom capability mapping, which is tied to the production SVN access
+ * table, so these tests cover the access control boundary.
+ *
+ * @package WordPressdotorg\Plugin_Directory\Tests
+ */
+
+/**
+ * Functional tests for the plugins/v1/plugin//committers endpoints.
+ *
+ * @group rest-api
+ */
+class Plugin_Committers_Test extends Plugin_Directory_Endpoint_TestCase {
+
+ /**
+ * ID of the plugin fixture.
+ *
+ * @var int
+ */
+ protected static $plugin_id;
+
+ /**
+ * ID of the user fixture.
+ *
+ * @var int
+ */
+ protected static $user_id;
+
+ /**
+ * Creates the plugin and user fixtures.
+ */
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+
+ self::$plugin_id = self::create_plugin( 'committers-test-plugin', 'Committers Test Plugin' );
+ self::$user_id = wp_insert_user(
+ array(
+ 'user_login' => 'committers-test-user',
+ 'user_pass' => wp_generate_password(),
+ 'user_email' => 'committers-test-user@example.org',
+ )
+ );
+ }
+
+ /**
+ * Deletes the fixtures.
+ */
+ public static function tearDownAfterClass(): void {
+ wp_delete_post( self::$plugin_id, true );
+ wp_delete_user( self::$user_id );
+
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * Listing committers requires permission on the plugin.
+ */
+ public function test_listing_requires_permission() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/plugin/committers-test-plugin/committers' );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ }
+
+ /**
+ * Adding a committer requires permission on the plugin.
+ */
+ public function test_adding_requires_permission() {
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/plugin/committers-test-plugin/committers' );
+ $request->set_param( 'committer', 'someuser' );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ }
+
+ /**
+ * Removing a committer requires permission on the plugin.
+ */
+ public function test_removing_requires_permission() {
+ $request = new WP_REST_Request( 'DELETE', '/plugins/v1/plugin/committers-test-plugin/committers/committers-test-user' );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Info_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Info_Test.php
new file mode 100644
index 0000000000..5cedabd9bd
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Info_Test.php
@@ -0,0 +1,98 @@
+ information endpoint.
+ *
+ * @package WordPressdotorg\Plugin_Directory\Tests
+ */
+
+/**
+ * Functional tests for the plugins/v1/plugin/ information endpoint.
+ *
+ * @group rest-api
+ */
+class Plugin_Info_Test extends Plugin_Directory_Endpoint_TestCase {
+
+ /**
+ * ID of the published plugin fixture.
+ *
+ * @var int
+ */
+ protected static $plugin_id;
+
+ /**
+ * ID of the plugin author fixture.
+ *
+ * @var int
+ */
+ protected static $author_id;
+
+ /**
+ * Creates the author and plugin fixtures.
+ */
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+
+ self::$author_id = wp_insert_user(
+ array(
+ 'user_login' => 'info-test-author',
+ 'user_pass' => wp_generate_password(),
+ 'user_email' => 'info-test-author@example.org',
+ )
+ );
+
+ $args = array(
+ 'post_author' => self::$author_id,
+ );
+
+ self::$plugin_id = self::create_plugin( 'info-test-plugin', 'Info Test Plugin', $args );
+ }
+
+ /**
+ * Deletes the fixtures.
+ */
+ public static function tearDownAfterClass(): void {
+ wp_delete_post( self::$plugin_id, true );
+ wp_delete_user( self::$author_id );
+
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * The endpoint returns the plugin information.
+ */
+ public function test_returns_plugin_information() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/plugin/info-test-plugin' );
+ $response = self::server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'info-test-plugin', $data['slug'] );
+ $this->assertSame( 'Info Test Plugin', $data['name'] );
+ }
+
+ /**
+ * Hostile locale input is sanitized before reaching switch_to_locale()
+ * and does not break the response.
+ */
+ public function test_sanitizes_hostile_locale() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/plugin/info-test-plugin' );
+ $request->set_query_params( array( 'locale' => '../../evil' ) );
+
+ $response = self::server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'info-test-plugin', $data['slug'] );
+ }
+
+ /**
+ * Requests for unknown plugins are rejected as invalid.
+ */
+ public function test_rejects_unknown_plugin() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/plugin/not-a-registered-plugin' );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Release_Confirmation_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Release_Confirmation_Test.php
new file mode 100644
index 0000000000..87dc28684b
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Release_Confirmation_Test.php
@@ -0,0 +1,66 @@
+set_param( 'confirmations_required', 1 );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ }
+
+ /**
+ * Confirming a release is rejected for tags without a known release.
+ */
+ public function test_confirming_rejects_unknown_release() {
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/plugin/release-test-plugin/release-confirmation/1.0' );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Review_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Review_Test.php
new file mode 100644
index 0000000000..f93f271f98
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Review_Test.php
@@ -0,0 +1,62 @@
+dispatch( $request );
+
+ // 401 for anonymous requests, 403 for authenticated ones.
+ $this->assertContains( $response->get_status(), array( 401, 403 ) );
+ }
+
+ /**
+ * Access is denied for plugins that do not exist.
+ */
+ public function test_denies_unknown_plugin() {
+ $route = sprintf( '/plugins/v1/plugin-review/%d-%s', self::$plugin_id + 1000, str_repeat( 'f', 32 ) );
+ $request = new WP_REST_Request( 'GET', $route );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertContains( $response->get_status(), array( 401, 403 ) );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Self_Toggle_Preview_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Self_Toggle_Preview_Test.php
new file mode 100644
index 0000000000..a4cfde1301
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Self_Toggle_Preview_Test.php
@@ -0,0 +1,65 @@
+/self-toggle-preview endpoint.
+ *
+ * Toggling the preview requires the plugin_toggle_public_preview
+ * capability, which is granted through the plugin's custom capability
+ * mapping, so these tests cover the access control boundary.
+ *
+ * @package WordPressdotorg\Plugin_Directory\Tests
+ */
+
+/**
+ * Functional tests for the plugins/v1/plugin//self-toggle-preview endpoint.
+ *
+ * @group rest-api
+ */
+class Plugin_Self_Toggle_Preview_Test extends Plugin_Directory_Endpoint_TestCase {
+
+ /**
+ * ID of the plugin fixture.
+ *
+ * @var int
+ */
+ protected static $plugin_id;
+
+ /**
+ * Creates the plugin fixture.
+ */
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+
+ self::$plugin_id = self::create_plugin( 'preview-toggle-test-plugin', 'Preview Toggle Test Plugin' );
+ }
+
+ /**
+ * Deletes the plugin fixture.
+ */
+ public static function tearDownAfterClass(): void {
+ wp_delete_post( self::$plugin_id, true );
+
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * Toggling the preview requires permission on the plugin.
+ */
+ public function test_toggling_requires_permission() {
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/plugin/preview-toggle-test-plugin/self-toggle-preview' );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ }
+
+ /**
+ * Dismissing the blueprint notice requires permission on the plugin.
+ */
+ public function test_dismissing_requires_permission() {
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/plugin/preview-toggle-test-plugin/self-toggle-preview' );
+ $request->set_param( 'dismiss', true );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Support_Reps_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Support_Reps_Test.php
new file mode 100644
index 0000000000..a7a46670c7
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Support_Reps_Test.php
@@ -0,0 +1,90 @@
+/support-reps endpoints.
+ *
+ * The support rep management capabilities are granted through the plugin's
+ * custom capability mapping, so these tests cover the access control
+ * boundary.
+ *
+ * @package WordPressdotorg\Plugin_Directory\Tests
+ */
+
+/**
+ * Functional tests for the plugins/v1/plugin//support-reps endpoints.
+ *
+ * @group rest-api
+ */
+class Plugin_Support_Reps_Test extends Plugin_Directory_Endpoint_TestCase {
+
+ /**
+ * ID of the plugin fixture.
+ *
+ * @var int
+ */
+ protected static $plugin_id;
+
+ /**
+ * ID of the user fixture.
+ *
+ * @var int
+ */
+ protected static $user_id;
+
+ /**
+ * Creates the plugin and user fixtures.
+ */
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+
+ self::$plugin_id = self::create_plugin( 'support-reps-test-plugin', 'Support Reps Test Plugin' );
+ self::$user_id = wp_insert_user(
+ array(
+ 'user_login' => 'support-reps-test-user',
+ 'user_pass' => wp_generate_password(),
+ 'user_email' => 'support-reps-test-user@example.org',
+ )
+ );
+ }
+
+ /**
+ * Deletes the fixtures.
+ */
+ public static function tearDownAfterClass(): void {
+ wp_delete_post( self::$plugin_id, true );
+ wp_delete_user( self::$user_id );
+
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * Listing support reps requires permission on the plugin.
+ */
+ public function test_listing_requires_permission() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/plugin/support-reps-test-plugin/support-reps' );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ }
+
+ /**
+ * Adding a support rep requires permission on the plugin.
+ */
+ public function test_adding_requires_permission() {
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/plugin/support-reps-test-plugin/support-reps' );
+ $request->set_param( 'support_rep', 'someuser' );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ }
+
+ /**
+ * Removing a support rep requires permission on the plugin.
+ */
+ public function test_removing_requires_permission() {
+ $request = new WP_REST_Request( 'DELETE', '/plugins/v1/plugin/support-reps-test-plugin/support-reps/support-reps-test-user' );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Upload_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Upload_Test.php
new file mode 100644
index 0000000000..d32e93a876
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Upload_Test.php
@@ -0,0 +1,128 @@
+ 'upload-test-author',
+ 'user_pass' => wp_generate_password(),
+ 'user_email' => 'upload-test-author@example.org',
+ )
+ );
+
+ $args = array(
+ 'post_status' => 'new',
+ 'post_author' => self::$author_id,
+ );
+
+ self::$plugin_id = self::create_plugin( 'upload-test-plugin', 'Upload Test Plugin', $args );
+ }
+
+ /**
+ * Deletes the fixtures.
+ */
+ public static function tearDownAfterClass(): void {
+ wp_delete_post( self::$plugin_id, true );
+ wp_delete_user( self::$author_id );
+ wp_set_current_user( 0 );
+
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * Resets the current user after each test.
+ */
+ protected function tearDown(): void {
+ wp_set_current_user( 0 );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Changing a slug requires being the plugin author or a reviewer.
+ */
+ public function test_slug_change_requires_permission() {
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/upload/' . self::$plugin_id . '/slug' );
+ $request->set_param( 'post_name', 'some-other-plugin-slug' );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ }
+
+ /**
+ * The slug is required when requesting a slug change.
+ */
+ public function test_slug_change_requires_slug() {
+ wp_set_current_user( self::$author_id );
+
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/upload/' . self::$plugin_id . '/slug' );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_missing_callback_param', $response->get_data()['code'] );
+ }
+
+ /**
+ * Invalid slugs are rejected.
+ */
+ public function test_slug_change_rejects_invalid_slug() {
+ wp_set_current_user( self::$author_id );
+
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/upload/' . self::$plugin_id . '/slug' );
+ $request->set_param( 'post_name', 'Not A Valid Slug!' );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'error', $response->get_data()['code'] );
+ $this->assertSame( 'upload-test-plugin', get_post( self::$plugin_id )->post_name );
+ }
+
+ /**
+ * The plugin author can change the slug of their newly submitted plugin.
+ */
+ public function test_author_can_change_slug() {
+ wp_set_current_user( self::$author_id );
+
+ $request = new WP_REST_Request( 'POST', '/plugins/v1/upload/' . self::$plugin_id . '/slug' );
+ $request->set_param( 'post_name', 'aurora-borealis-notes' );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $response->get_data() );
+ $this->assertSame( 'aurora-borealis-notes', get_post( self::$plugin_id )->post_name );
+ $this->assertSame( 'upload-test-plugin', get_post_meta( self::$plugin_id, '_wporg_plugin_original_slug', true ) );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Query_Plugins_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Query_Plugins_Test.php
new file mode 100644
index 0000000000..1d7909e2e2
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Query_Plugins_Test.php
@@ -0,0 +1,145 @@
+set_param( 'paged', 1 );
+
+ $response = self::server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertGreaterThanOrEqual( 2, $data['info']['results'] );
+ $this->assertContains( 'query-test-plugin-alpha', $data['plugins'] );
+ $this->assertContains( 'query-test-plugin-beta', $data['plugins'] );
+ }
+
+ /**
+ * The slug: search bypass returns exactly the requested plugin.
+ */
+ public function test_finds_exact_plugin_by_slug() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/query-plugins' );
+ $request->set_query_params( array( 's' => 'slug:query-test-plugin-alpha' ) );
+
+ $response = self::server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( array( 'query-test-plugin-alpha' ), $data['plugins'] );
+ $this->assertSame( 1, $data['info']['results'] );
+ }
+
+ /**
+ * Results are paginated according to posts_per_page, including when
+ * passed as a numeric string like existing consumers do.
+ */
+ public function test_paginates_results() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/query-plugins' );
+ $request->set_query_params(
+ array(
+ 'paged' => '1',
+ 'posts_per_page' => '1',
+ )
+ );
+
+ $response = self::server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertCount( 1, $data['plugins'] );
+ $this->assertGreaterThanOrEqual( 2, $data['info']['pages'] );
+ }
+
+ /**
+ * A query without any recognised parameters returns the empty response.
+ */
+ public function test_empty_query_returns_no_results() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/query-plugins' );
+
+ $response = self::server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 0, $data['info']['results'] );
+ $this->assertSame( array(), $data['plugins'] );
+ }
+
+ /**
+ * Malformed pagination input is coerced rather than rejected, as
+ * api.wordpress.org proxies client input into this route verbatim.
+ */
+ public function test_tolerates_invalid_pagination() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/query-plugins' );
+ $request->set_query_params( array(
+ 'paged' => 'not-a-number',
+ 'posts_per_page' => '',
+ ) );
+
+ $response = self::server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertContains( 'query-test-plugin-alpha', $data['plugins'] );
+ }
+
+ /**
+ * Array input for the scalar search parameter is sanitized away
+ * instead of being passed into WP_Query.
+ */
+ public function test_tolerates_array_search() {
+ $request = new WP_REST_Request( 'GET', '/plugins/v1/query-plugins' );
+ $request->set_query_params( array( 's' => array( 'evil' => 'array' ) ) );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ }
+}
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..eeb8452fe4 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,6 @@ function manually_load_plugin() {
// Start up the WP testing environment.
require $_tests_dir . '/includes/bootstrap.php';
+
+// Shared base class for the REST API endpoint tests.
+require_once __DIR__ . '/Endpoint_TestCase.php';
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/phpunit.xml b/wordpress.org/public_html/wp-content/plugins/theme-directory/phpunit.xml
index 71b6007751..0b23b06333 100644
--- a/wordpress.org/public_html/wp-content/plugins/theme-directory/phpunit.xml
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/phpunit.xml
@@ -7,6 +7,7 @@
tests/
tests/bootstrap.php
+ tests/Endpoint_TestCase.php
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api.php
index a0c64a1b09..28dc0c6eb9 100644
--- a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api.php
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api.php
@@ -1,6 +1,18 @@
array( $this, 'shops' ),
'permission_callback' => '__return_true',
+ 'args' => array(
+ // Only its presence is checked, to bypass the response cache.
+ 'cache_buster' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
);
register_rest_route( 'themes/1.0', 'commercial-shops', $args );
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-features-endpoint.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-features-endpoint.php
index 2f8974d428..14ffcc7769 100644
--- a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-features-endpoint.php
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-features-endpoint.php
@@ -9,6 +9,18 @@ function __construct() {
$args = array(
'callback' => array( $this, 'features' ),
'permission_callback' => '__return_true',
+ 'args' => array(
+ 'wp_version' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'locale' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => __NAMESPACE__ . '\\sanitize_locale',
+ ),
+ ),
);
register_rest_route( 'themes/1.0', 'features', $args );
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-info-endpoint.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-info-endpoint.php
index 125e9e8189..1571106941 100644
--- a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-info-endpoint.php
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-info-endpoint.php
@@ -9,6 +9,25 @@ function __construct() {
$args = array(
'callback' => array( $this, 'info' ),
'permission_callback' => '__return_true',
+ 'args' => array(
+ 'slug' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'slugs' => array(
+ 'type' => array( 'string', 'array' ),
+ 'items' => array( 'type' => 'string' ),
+ ),
+ 'fields' => array(
+ 'type' => array( 'string', 'array', 'object' ),
+ ),
+ 'locale' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => __NAMESPACE__ . '\sanitize_locale',
+ ),
+ ),
);
register_rest_route( 'themes/1.0', 'info(/(?P[^/]+))?', $args );
@@ -32,7 +51,7 @@ function info( $request ) {
if ( ! empty( $api->bad_input ) ) {
$response->set_status( 400 );
- } elseif ( ! empty( $api->error ) && 'Theme not found' === $api->error ) {
+ } elseif ( 'Theme not found' === ( $api->response->error ?? '' ) ) {
$response->set_status( 404 );
}
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-internal.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-internal.php
index 863d0a4a2f..6c112a7922 100644
--- a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-internal.php
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-internal.php
@@ -11,6 +11,13 @@ function __construct() {
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'bulk_update_stats' ),
'permission_callback' => array( $this, 'permission_check_bearer' ),
+ 'args' => array(
+ // Entries are unconstrained so one malformed theme cannot reject the batch.
+ 'themes' => array(
+ 'type' => 'object',
+ 'required' => true,
+ ),
+ ),
) );
register_rest_route( 'themes/v1', 'svn-auth', array(
@@ -115,6 +122,10 @@ function bulk_update_stats( $request ) {
$data = $request['themes'];
foreach ( $data as $theme_slug => $stats ) {
+ if ( ! is_array( $stats ) ) {
+ continue;
+ }
+
$theme = get_posts( array(
'name' => $theme_slug,
'posts_per_page' => 1,
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-query-endpoint.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-query-endpoint.php
index 22c55e130c..65638c7b17 100644
--- a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-query-endpoint.php
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-query-endpoint.php
@@ -9,6 +9,51 @@ function __construct() {
$args = array(
'callback' => array( $this, 'query' ),
'permission_callback' => '__return_true',
+ 'args' => array(
+ 'page' => array(
+ 'type' => 'integer',
+ ),
+ 'per_page' => array(
+ 'type' => 'integer',
+ ),
+ 'browse' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'user' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'tag' => array(
+ 'type' => array( 'string', 'array' ),
+ 'items' => array( 'type' => 'string' ),
+ ),
+ 'search' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'theme' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'author' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'fields' => array(
+ 'type' => array( 'string', 'array', 'object' ),
+ ),
+ 'locale' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => __NAMESPACE__ . '\sanitize_locale',
+ ),
+ ),
);
register_rest_route( 'themes/1.0', 'query', $args );
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-tags-endpoint.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-tags-endpoint.php
index 0db82f57a6..0f426414d2 100644
--- a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-tags-endpoint.php
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-tags-endpoint.php
@@ -7,6 +7,16 @@ function __construct() {
$args = array(
'callback' => array( $this, 'tags' ),
'permission_callback' => '__return_true',
+ 'args' => array(
+ 'number' => array(
+ 'type' => 'integer',
+ ),
+ 'locale' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => __NAMESPACE__ . '\sanitize_locale',
+ ),
+ ),
);
register_rest_route( 'themes/1.0', 'tags', $args );
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-theme-preview.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-theme-preview.php
index eb0298d290..235e07d547 100644
--- a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-theme-preview.php
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-theme-preview.php
@@ -14,7 +14,7 @@ function __construct() {
'args' => array(
'slug' => array(
'type' => 'string',
- 'required' => 'true',
+ 'required' => true,
'sanitize_callback' => 'sanitize_key',
),
),
@@ -47,11 +47,16 @@ function __construct() {
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'set_blueprint' ),
'args' => array(
- 'slug' => array(
+ 'slug' => array(
'type' => 'string',
- 'required' => 'true',
+ 'required' => true,
'sanitize_callback' => 'sanitize_key',
),
+ // A JSON-encoded Playground blueprint, decoded and validated in the callback.
+ 'blueprint' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
),
'permission_callback' => function( $request ) {
$theme_data = wporg_themes_theme_information( $request['slug'] );
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-theme-review-stats.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-theme-review-stats.php
index 7b17045bb7..6b681135d2 100644
--- a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-theme-review-stats.php
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-theme-review-stats.php
@@ -46,6 +46,13 @@ public function register_routes() {
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_by_theme_type' ),
'permission_callback' => '__return_true',
+ 'args' => array(
+ 'startDate' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
)
);
@@ -56,6 +63,13 @@ public function register_routes() {
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_by_segment' ),
'permission_callback' => '__return_true',
+ 'args' => array(
+ 'startDate' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
)
);
@@ -66,6 +80,13 @@ public function register_routes() {
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_by_author_type' ),
'permission_callback' => '__return_true',
+ 'args' => array(
+ 'startDate' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
)
);
@@ -76,6 +97,13 @@ public function register_routes() {
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_review_days' ),
'permission_callback' => '__return_true',
+ 'args' => array(
+ 'startDate' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
)
);
}
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-themes-auto-review.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-themes-auto-review.php
index f3d808e3e6..b2f5b3e2b5 100644
--- a/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-themes-auto-review.php
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/rest-api/class-themes-auto-review.php
@@ -37,6 +37,16 @@ public function register_routes() {
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_item' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
+ 'args' => array(
+ 'theme_slug' => array(
+ 'type' => 'string',
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'ticket_id' => array(
+ 'type' => 'integer',
+ ),
+ ),
)
);
}
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Endpoint_TestCase.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Endpoint_TestCase.php
new file mode 100644
index 0000000000..1619c306b7
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Endpoint_TestCase.php
@@ -0,0 +1,111 @@
+ 'repopackage',
+ 'post_status' => 'publish',
+ 'post_name' => $slug,
+ 'post_title' => $title,
+ 'post_author' => self::theme_author(),
+ // Version and screenshot meta, which fill_theme() requires.
+ 'meta_input' => array(
+ '_status' => array( '1.0' => 'live' ),
+ '_screenshot' => array( '1.0' => 'screenshot.png' ),
+ ),
+ );
+
+ return wp_insert_post( wp_parse_args( $args, $defaults ) );
+ }
+
+ /**
+ * Returns the shared theme author fixture, creating it if necessary.
+ *
+ * @return int The user ID.
+ */
+ protected static function theme_author() {
+ $author = get_user_by( 'login', 'theme-endpoint-test-author' );
+ if ( $author ) {
+ return $author->ID;
+ }
+
+ return wp_insert_user(
+ array(
+ 'user_login' => 'theme-endpoint-test-author',
+ 'user_pass' => wp_generate_password(),
+ 'user_email' => 'theme-endpoint-test-author@example.org',
+ )
+ );
+ }
+
+ /**
+ * Deletes a theme post.
+ *
+ * The plugin prevents repopackages from being deleted; detaches that
+ * specific guard while cleaning up the fixture post.
+ *
+ * @param int $post_id The post ID.
+ */
+ protected static function delete_theme( $post_id ) {
+ remove_filter( 'before_delete_post', 'wporg_theme_no_delete_repopackage' );
+ wp_delete_post( $post_id, true );
+ add_filter( 'before_delete_post', 'wporg_theme_no_delete_repopackage' );
+ }
+
+ /**
+ * Creates post_tag terms.
+ *
+ * The plugin restricts term creation to super admins; detaches that
+ * specific guard while creating the fixture terms.
+ *
+ * @param string[] $tags The tag names to create.
+ * @return int[] The created term IDs.
+ */
+ protected static function create_tags( $tags ) {
+ $term_ids = array();
+
+ remove_filter( 'pre_insert_term', 'wporg_themes_pre_insert_term' );
+ foreach ( $tags as $tag ) {
+ $term = wp_insert_term( $tag, 'post_tag' );
+ if ( ! is_wp_error( $term ) ) {
+ $term_ids[] = $term['term_id'];
+ }
+ }
+ add_filter( 'pre_insert_term', 'wporg_themes_pre_insert_term' );
+
+ return $term_ids;
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Info_Endpoint_Test.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Info_Endpoint_Test.php
new file mode 100644
index 0000000000..fcb36e9d4a
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Info_Endpoint_Test.php
@@ -0,0 +1,75 @@
+dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'info-test-theme', $data->slug );
+ $this->assertSame( 'Info Test Theme', $data->name );
+ }
+
+ /**
+ * Unknown themes return a 404.
+ */
+ public function test_returns_404_for_unknown_theme() {
+ $request = new WP_REST_Request( 'GET', '/themes/1.1/info/not-a-registered-theme' );
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 404, $response->get_status() );
+ }
+
+ /**
+ * Array input for the scalar slug parameter is rejected.
+ */
+ public function test_rejects_array_slug() {
+ $request = new WP_REST_Request( 'GET', '/themes/1.1/info' );
+ $request->set_query_params( array( 'slug' => array( 'evil' => 'array' ) ) );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Internal_Endpoints_Test.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Internal_Endpoints_Test.php
new file mode 100644
index 0000000000..42fc256e89
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Internal_Endpoints_Test.php
@@ -0,0 +1,155 @@
+set_param( 'themes', array( 'stats-test-theme' => array( 'active_installs' => 100 ) ) );
+ $request->set_header( 'Authorization', 'Bearer not-the-right-token' );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'not_authorized', $response->get_data()['code'] );
+ }
+
+ /**
+ * Active install counts are rounded down and written to the theme,
+ * while unknown themes are silently skipped.
+ */
+ public function test_update_stats_updates_theme_stats() {
+ $stats = array(
+ 'stats-test-theme' => array(
+ 'active_installs' => 1234,
+ ),
+ 'not-a-registered-theme' => array(
+ 'active_installs' => 100,
+ ),
+ );
+
+ $request = $this->authorized_request();
+ $request->set_param( 'themes', $stats );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $response->get_data() );
+ $this->assertEquals( 1000, get_post_meta( self::$theme_id, '_active_installs', true ) );
+ }
+
+ /**
+ * A malformed entry is skipped without rejecting the rest of the batch.
+ */
+ public function test_update_stats_skips_malformed_entries() {
+ $stats = array(
+ 'stats-test-theme' => array(
+ 'active_installs' => 5678,
+ ),
+ 'malformed-entry' => 'not-an-object',
+ );
+
+ $request = $this->authorized_request();
+ $request->set_param( 'themes', $stats );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $response->get_data() );
+ $this->assertEquals( 5000, get_post_meta( self::$theme_id, '_active_installs', true ) );
+ }
+
+ /**
+ * A scalar themes parameter is rejected instead of being iterated.
+ */
+ public function test_update_stats_rejects_malformed_body() {
+ $request = $this->authorized_request();
+ $request->set_param( 'themes', 'not-an-object' );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
+ }
+
+ /**
+ * A request without stats data is rejected.
+ */
+ public function test_update_stats_requires_stats_data() {
+ $request = $this->authorized_request();
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_missing_callback_param', $response->get_data()['code'] );
+ }
+
+ /**
+ * The SVN auth file requires its bearer token.
+ */
+ public function test_svn_auth_requires_authentication() {
+ $request = new WP_REST_Request( 'GET', '/themes/v1/svn-auth' );
+ $request->set_header( 'Authorization', 'Bearer not-the-right-token' );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'not_authorized', $response->get_data()['code'] );
+ }
+
+ /**
+ * Builds an update-stats request carrying the internal bearer token.
+ *
+ * @return WP_REST_Request The authorized request.
+ */
+ protected function authorized_request() {
+ if ( ! defined( 'THEME_API_INTERNAL_BEARER_TOKEN' ) ) {
+ define( 'THEME_API_INTERNAL_BEARER_TOKEN', 'theme-directory-test-token' );
+ }
+
+ $request = new WP_REST_Request( 'POST', '/themes/v1/update-stats' );
+ $request->set_header( 'Authorization', 'Bearer ' . THEME_API_INTERNAL_BEARER_TOKEN );
+
+ return $request;
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Query_Endpoint_Test.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Query_Endpoint_Test.php
new file mode 100644
index 0000000000..4b75d3707f
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Query_Endpoint_Test.php
@@ -0,0 +1,104 @@
+set_query_params( array( 'theme' => 'query-test-theme' ) );
+
+ $response = self::server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 1, $data->info['results'] );
+ $this->assertSame( 'query-test-theme', $data->themes[0]->slug );
+ }
+
+ /**
+ * An unmatched search returns the empty result structure, including
+ * when pagination is passed as numeric strings like existing
+ * consumers do.
+ */
+ public function test_unmatched_search_returns_empty_results() {
+ $request = new WP_REST_Request( 'GET', '/themes/1.1/query' );
+ $request->set_query_params(
+ array(
+ 'search' => 'no-theme-matches-this-search-term',
+ 'page' => '1',
+ 'per_page' => '5',
+ )
+ );
+
+ $response = self::server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertObjectHasProperty( 'info', $data );
+ $this->assertSame( 0, $data->info['results'] );
+ }
+
+ /**
+ * Array input for scalar parameters is rejected with a 400, as the
+ * Themes_API bad_input handling already did.
+ */
+ public function test_rejects_array_search() {
+ $request = new WP_REST_Request( 'GET', '/themes/1.1/query' );
+ $request->set_query_params( array( 'search' => array( 'evil' => 'array' ) ) );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
+ }
+
+ /**
+ * Non-numeric pagination input is rejected.
+ */
+ public function test_rejects_invalid_page() {
+ $request = new WP_REST_Request( 'GET', '/themes/1.1/query' );
+ $request->set_query_params( array( 'page' => 'not-a-number' ) );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Tags_Endpoint_Test.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Tags_Endpoint_Test.php
new file mode 100644
index 0000000000..55eebdbba4
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Tags_Endpoint_Test.php
@@ -0,0 +1,82 @@
+dispatch( $request );
+ $data = (array) $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertArrayHasKey( 'tags-test-one', $data );
+ $this->assertSame( 'tags-test-one', $data['tags-test-one']['slug'] );
+ $this->assertArrayHasKey( 'name', $data['tags-test-one'] );
+ $this->assertArrayHasKey( 'count', $data['tags-test-one'] );
+ }
+
+ /**
+ * The number parameter limits the result, including when passed as a
+ * numeric string like existing consumers do.
+ */
+ public function test_limits_number_of_tags() {
+ $request = new WP_REST_Request( 'GET', '/themes/1.1/tags' );
+ $request->set_query_params( array( 'number' => '2' ) );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertCount( 2, (array) $response->get_data() );
+ }
+
+ /**
+ * A non-numeric number parameter is rejected.
+ */
+ public function test_rejects_invalid_number() {
+ $request = new WP_REST_Request( 'GET', '/themes/1.1/tags' );
+ $request->set_query_params( array( 'number' => 'not-a-number' ) );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Theme_Preview_Test.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Theme_Preview_Test.php
new file mode 100644
index 0000000000..2d5deb18ea
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Theme_Preview_Test.php
@@ -0,0 +1,75 @@
+dispatch( $request );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'error', $response->get_data()['code'] );
+ }
+
+ /**
+ * Previewing a theme returns a Playground blueprint.
+ */
+ public function test_preview_returns_blueprint() {
+ $request = new WP_REST_Request( 'GET', '/themes/v1/preview-blueprint/preview-test-theme' );
+ $response = self::server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertArrayHasKey( 'steps', $data );
+ $this->assertNotEmpty( $data['steps'] );
+ }
+
+ /**
+ * Storing a blueprint requires being the theme author.
+ */
+ public function test_set_blueprint_requires_permission() {
+ $request = new WP_REST_Request( 'POST', '/themes/v1/preview-blueprint/preview-test-theme' );
+ $request->set_param( 'blueprint', wp_json_encode( array( 'steps' => array() ) ) );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertContains( $response->get_status(), array( 401, 403 ) );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Theme_Review_Stats_Test.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Theme_Review_Stats_Test.php
new file mode 100644
index 0000000000..a4bd653bc9
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Theme_Review_Stats_Test.php
@@ -0,0 +1,71 @@
+dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ }
+
+ /**
+ * The public stats endpoints return monthly data rows.
+ *
+ * @dataProvider data_public_stats_routes
+ *
+ * @param string $route The stats route.
+ */
+ public function test_returns_stats( $route ) {
+ $request = new WP_REST_Request( 'GET', $route );
+ $request->set_query_params( array( 'startDate' => '2024-01-01' ) );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertIsArray( $response->get_data() );
+ }
+
+ /**
+ * The public stats endpoints reject non-scalar startDate input.
+ *
+ * @dataProvider data_public_stats_routes
+ *
+ * @param string $route The stats route.
+ */
+ public function test_rejects_array_start_date( $route ) {
+ $request = new WP_REST_Request( 'GET', $route );
+ $request->set_query_params( array( 'startDate' => array( 'evil' => 'array' ) ) );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ }
+
+ /**
+ * Data provider of the public stats routes.
+ *
+ * @return array[]
+ */
+ public static function data_public_stats_routes() {
+ return array(
+ 'byThemeType' => array( '/themes/v1/stats/byThemeType' ),
+ 'bySegment' => array( '/themes/v1/stats/bySegment' ),
+ 'byAuthorType' => array( '/themes/v1/stats/byAuthorType' ),
+ 'reviewDays' => array( '/themes/v1/stats/reviewDays' ),
+ );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Themes_Auto_Review_Test.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Themes_Auto_Review_Test.php
new file mode 100644
index 0000000000..f820397eb7
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Themes_Auto_Review_Test.php
@@ -0,0 +1,31 @@
+set_header( 'Authorization', 'Bearer not-the-right-token' );
+ $request->set_body( 'Test results' );
+
+ $response = self::server()->dispatch( $request );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'not_authorized', $response->get_data()['code'] );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php
index e232641bc1..155ba42d2b 100644
--- a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php
+++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php
@@ -58,3 +58,6 @@ function manually_load_plugin() {
// Start up the WP testing environment.
require $_tests_dir . '/includes/bootstrap.php';
+
+// Shared base class for the REST API endpoint tests.
+require_once __DIR__ . '/Endpoint_TestCase.php';
diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-bbp-also-viewing/wporg-bbp-also-viewing.php b/wordpress.org/public_html/wp-content/plugins/wporg-bbp-also-viewing/wporg-bbp-also-viewing.php
index 288086c5bb..a3ac5f473e 100644
--- a/wordpress.org/public_html/wp-content/plugins/wporg-bbp-also-viewing/wporg-bbp-also-viewing.php
+++ b/wordpress.org/public_html/wp-content/plugins/wporg-bbp-also-viewing/wporg-bbp-also-viewing.php
@@ -439,6 +439,14 @@ function clear_viewing( $page = null, $user_id = false ) {
*/
function rest_api_init() {
register_rest_route( 'wporg/v1', '/currentlyViewing/(?P.+)', [
+ 'args' => [
+ 'page' => [
+ 'type' => 'string',
+ 'required' => true,
+ 'validate_callback' => 'rest_validate_request_arg',
+ 'sanitize_callback' => __NAMESPACE__ . '\sanitize_page_url_for_db',
+ ],
+ ],
[
'methods' => 'GET',
'callback' => function( $request ) {
@@ -459,6 +467,11 @@ function rest_api_init() {
'permission_callback' => function() {
return is_user_logged_in() && enabled();
},
+ 'args' => [
+ 'isTyping' => [
+ 'type' => 'boolean',
+ ],
+ ],
],
[
'methods' => 'DELETE',