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',