From 70d14606552f17c51eebb5cd904d34f36ea12fb6 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Sat, 25 Jul 2026 09:51:49 -0500 Subject: [PATCH 1/4] Coding Standards: Exempt test file names from the hyphenation rule. PHPUnit discovers test classes by deriving the class name from the file basename, and PHP class names cannot contain hyphens, so test files can never satisfy the hyphenated-lowercase file name sniff. WordPress core carries the same exemption for its test suite. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JPDKVeVpBoWoB8euRB5hK2 --- phpcs.xml.dist | 5 +++++ 1 file changed, 5 insertions(+) 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/* + + From c65fbe5fe3ee2f79228597b57ca7ba5052ccb3c3 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Sat, 25 Jul 2026 09:51:49 -0500 Subject: [PATCH 2/4] Plugin Directory: Add argument schemas to REST API routes. Several plugins/v1 routes consumed request parameters that were never declared in their route registration, leaving them without validation or sanitization: query-plugins fed eleven undeclared parameters into WP_Query, update-stats iterated an untyped body parameter, and locale values reached switch_to_locale() unchecked. Declares every consumed parameter with types, requirements, and validate/sanitize callbacks, moving URL placeholder args to shared route-level declarations. Adds functional test suites for each endpoint, exercising queries, stat updates, slug changes, scan callbacks, and access control end-to-end. The tests also surfaced that the plugin information endpoint emitted warnings when review data is unavailable, now guarded in get_plugin_reviews_markup(). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JPDKVeVpBoWoB8euRB5hK2 --- .../plugin-directory/api/class-base.php | 16 ++ .../api/routes/class-gandalf-scan.php | 40 ++++- .../api/routes/class-internal-stats.php | 11 ++ .../api/routes/class-plugin-blueprint.php | 13 ++ .../api/routes/class-plugin-committers.php | 19 +-- .../class-plugin-release-confirmation.php | 11 +- .../api/routes/class-plugin-review.php | 9 ++ .../class-plugin-self-toggle-preview.php | 4 + .../api/routes/class-plugin-support-reps.php | 19 +-- .../api/routes/class-plugin-upload.php | 6 + .../api/routes/class-plugin.php | 6 +- .../api/routes/class-query-plugins.php | 54 +++++++ .../jobs/class-plugin-scan-gandalf.php | 16 +- .../plugins/plugin-directory/phpunit.xml | 1 + .../tests/Endpoint_TestCase.php | 66 ++++++++ .../tests/Gandalf_Scan_Test.php | 140 +++++++++++++++++ .../tests/Internal_Stats_Test.php | 142 +++++++++++++++++ .../tests/Plugin_Blueprint_Test.php | 61 ++++++++ .../tests/Plugin_Committers_Test.php | 90 +++++++++++ .../tests/Plugin_Info_Test.php | 98 ++++++++++++ .../Plugin_Release_Confirmation_Test.php | 66 ++++++++ .../tests/Plugin_Review_Test.php | 62 ++++++++ .../tests/Plugin_Self_Toggle_Preview_Test.php | 65 ++++++++ .../tests/Plugin_Support_Reps_Test.php | 90 +++++++++++ .../tests/Plugin_Upload_Test.php | 128 ++++++++++++++++ .../tests/Query_Plugins_Test.php | 145 ++++++++++++++++++ .../plugin-directory/tests/bootstrap.php | 3 + 27 files changed, 1352 insertions(+), 29 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Endpoint_TestCase.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Internal_Stats_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Blueprint_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Committers_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Info_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Release_Confirmation_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Review_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Self_Toggle_Preview_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Support_Reps_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Upload_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Query_Plugins_Test.php 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'; From af7c725861832815029ba7c0455171f0420d011f Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Sat, 25 Jul 2026 09:51:49 -0500 Subject: [PATCH 3/4] Theme Directory: Add argument schemas to REST API routes. The themes/1.x query, info, and tags endpoints passed all request parameters into Themes_API without route-level declarations, the public stats endpoints consumed an undeclared startDate, update-stats iterated an untyped body parameter, and the preview-blueprint POST route persisted an undeclared blueprint payload. Declares every consumed parameter with types and validate/sanitize callbacks, and fixes two 'required' => 'true' string typos. Adds functional test suites for each endpoint, exercising theme queries, information lookups, tags, stats, stat updates, blueprints, and access control end-to-end. The tests also surfaced that the info endpoint's 404 handling checked a nonexistent property and never fired, now reading the error from the API response. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JPDKVeVpBoWoB8euRB5hK2 --- .../plugins/theme-directory/phpunit.xml | 1 + .../plugins/theme-directory/rest-api.php | 12 ++ .../class-commercial-shops-endpoint.php | 8 + .../rest-api/class-features-endpoint.php | 12 ++ .../rest-api/class-info-endpoint.php | 21 ++- .../rest-api/class-internal.php | 11 ++ .../rest-api/class-query-endpoint.php | 45 +++++ .../rest-api/class-tags-endpoint.php | 10 ++ .../rest-api/class-theme-preview.php | 11 +- .../rest-api/class-theme-review-stats.php | 28 ++++ .../rest-api/class-themes-auto-review.php | 10 ++ .../tests/Endpoint_TestCase.php | 111 +++++++++++++ .../tests/Info_Endpoint_Test.php | 75 +++++++++ .../tests/Internal_Endpoints_Test.php | 155 ++++++++++++++++++ .../tests/Query_Endpoint_Test.php | 104 ++++++++++++ .../tests/Tags_Endpoint_Test.php | 82 +++++++++ .../tests/Theme_Preview_Test.php | 75 +++++++++ .../tests/Theme_Review_Stats_Test.php | 71 ++++++++ .../tests/Themes_Auto_Review_Test.php | 31 ++++ .../theme-directory/tests/bootstrap.php | 3 + 20 files changed, 872 insertions(+), 4 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Endpoint_TestCase.php create mode 100644 wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Info_Endpoint_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Internal_Endpoints_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Query_Endpoint_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Tags_Endpoint_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Theme_Preview_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Theme_Review_Stats_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Themes_Auto_Review_Test.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'; From b929d15e5d8ae00c5a535596e63a92dd448208c8 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Sat, 25 Jul 2026 09:51:50 -0500 Subject: [PATCH 4/4] Also Viewing: Add argument schemas to the currentlyViewing REST route. The route consumed the page placeholder and the isTyping flag without declaring them, leaving the unconstrained URL capture without a sanitizer at the route layer. Declares page as a required, sanitized route-level argument shared by all three methods, and types isTyping as a boolean on the POST handler. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JPDKVeVpBoWoB8euRB5hK2 --- .../wporg-bbp-also-viewing.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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',