From a3c2f01fead9db74093f53f36bccefb21b852af5 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 15:19:56 -0700 Subject: [PATCH 01/10] Comments: Add a REST API endpoint for comment types. Expose registered comment types through a read-only `/wp/v2/comment-types` controller, mirroring the post types controller (`/wp/v2/types`). This lets REST clients discover the registered types and their labels, which the block editor's inline-commenting work needs in order to add and query comments by type. Add a `show_in_rest` argument to `WP_Comment_Type` (defaulting to the value of `public`, as `show_ui` does) to gate which types the endpoint exposes. The built-in `comment`, `pingback`, and `trackback` types are public and therefore visible; the internal `note` type is not. Builds on the registration API in #12311. See #35214. --- src/wp-includes/class-wp-comment-type.php | 29 +- src/wp-includes/rest-api.php | 4 + ...class-wp-rest-comment-types-controller.php | 315 ++++++++++++++++++ src/wp-settings.php | 1 + 4 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 6f88c5e7b499d..5e8aa923f6012 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -94,6 +94,17 @@ final class WP_Comment_Type { */ public $show_ui; + /** + * Whether to include this comment type in the REST API. + * + * Comment types with this enabled are exposed by the `/wp/v2/comment-types` + * endpoint. Default is the value of $public. + * + * @since 7.1.0 + * @var bool + */ + public $show_in_rest; + /** * Whether this comment type is a native or "built-in" comment type. * @@ -187,12 +198,13 @@ public function set_props( $args ) { * treated as a provided value and overwrite the default name with false. */ $defaults = array( - 'labels' => array(), - 'description' => '', - 'public' => true, - 'internal' => false, - 'show_ui' => null, - '_builtin' => false, + 'labels' => array(), + 'description' => '', + 'public' => true, + 'internal' => false, + 'show_ui' => null, + 'show_in_rest' => null, + '_builtin' => false, ); $args = array_merge( $defaults, $args ); @@ -202,6 +214,11 @@ public function set_props( $args ) { $args['show_ui'] = $args['public']; } + // If not set, default to the setting for 'public'. + if ( null === $args['show_in_rest'] ) { + $args['show_in_rest'] = $args['public']; + } + $args['name'] = $this->name; foreach ( $args as $property_name => $property_value ) { diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index a4c22e8f1cca1..c49202811a8f1 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -324,6 +324,10 @@ function create_initial_rest_routes() { $controller = new WP_REST_Comments_Controller(); $controller->register_routes(); + // Comment types. + $controller = new WP_REST_Comment_Types_Controller(); + $controller->register_routes(); + $search_handlers = array( new WP_REST_Post_Search_Handler(), new WP_REST_Term_Search_Handler(), diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php new file mode 100644 index 0000000000000..f5913d1d9242a --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php @@ -0,0 +1,315 @@ +namespace = 'wp/v2'; + $this->rest_base = 'comment-types'; + } + + /** + * Registers the routes for comment types. + * + * @since 7.1.0 + * + * @see register_rest_route() + */ + public function register_routes() { + + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\w-]+)', + array( + 'args' => array( + 'type' => array( + 'description' => __( 'An alphanumeric identifier for the comment type.' ), + 'type' => 'string', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks whether a given request has permission to read comment types. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + if ( 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_cannot_view', + __( 'Sorry, you are not allowed to manage comments.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Retrieves all public comment types. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + if ( $request->is_method( 'HEAD' ) ) { + // Return early as this handler doesn't add any response headers. + return new WP_REST_Response( array() ); + } + + $data = array(); + $types = get_comment_types( array( 'show_in_rest' => true ), 'objects' ); + + foreach ( $types as $type ) { + $comment_type = $this->prepare_item_for_response( $type, $request ); + $data[ $type->name ] = $this->prepare_response_for_collection( $comment_type ); + } + + return rest_ensure_response( $data ); + } + + /** + * Checks if a given request has access to read a comment type. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + if ( 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'rest_forbidden_context', + __( 'Sorry, you are not allowed to manage comments.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Retrieves a specific comment type. + * + * @since 7.1.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $obj = get_comment_type_object( $request['type'] ); + + if ( empty( $obj ) ) { + return new WP_Error( + 'rest_type_invalid', + __( 'Invalid comment type.' ), + array( 'status' => 404 ) + ); + } + + if ( empty( $obj->show_in_rest ) ) { + return new WP_Error( + 'rest_cannot_read_type', + __( 'Cannot view comment type.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $data = $this->prepare_item_for_response( $obj, $request ); + + return rest_ensure_response( $data ); + } + + /** + * Prepares a comment type object for serialization. + * + * @since 7.1.0 + * + * @param WP_Comment_Type $item Comment type object. + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + // Restores the more descriptive, specific name for use within this method. + $comment_type = $item; + + // Don't prepare the response body for HEAD requests. + if ( $request->is_method( 'HEAD' ) ) { + /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php */ + return apply_filters( 'rest_prepare_comment_type', new WP_REST_Response( array() ), $comment_type, $request ); + } + + $fields = $this->get_fields_for_response( $request ); + $data = array(); + + if ( rest_is_field_included( 'description', $fields ) ) { + $data['description'] = $comment_type->description; + } + + if ( rest_is_field_included( 'labels', $fields ) ) { + $data['labels'] = $comment_type->labels; + } + + if ( rest_is_field_included( 'name', $fields ) ) { + $data['name'] = $comment_type->label; + } + + if ( rest_is_field_included( 'slug', $fields ) ) { + $data['slug'] = $comment_type->name; + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + // Wrap the data in a response object. + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $response->add_links( $this->prepare_links( $comment_type ) ); + } + + /** + * Filters a comment type returned from the REST API. + * + * Allows modification of the comment type data right before it is returned. + * + * @since 7.1.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Comment_Type $comment_type The original comment type object. + * @param WP_REST_Request $request Request used to generate the response. + */ + return apply_filters( 'rest_prepare_comment_type', $response, $comment_type, $request ); + } + + /** + * Prepares links for the request. + * + * @since 7.1.0 + * + * @param WP_Comment_Type $comment_type The comment type. + * @return array Links for the given comment type. + */ + protected function prepare_links( $comment_type ) { + return array( + 'collection' => array( + 'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ), + ), + 'https://api.w.org/items' => array( + 'href' => add_query_arg( 'type', $comment_type->name, rest_url( 'wp/v2/comments' ) ), + ), + ); + } + + /** + * Retrieves the comment type's schema, conforming to JSON Schema. + * + * @since 7.1.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'comment-type', + 'type' => 'object', + 'properties' => array( + 'description' => array( + 'description' => __( 'A human-readable description of the comment type.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'labels' => array( + 'description' => __( 'Human-readable labels for the comment type for various contexts.' ), + 'type' => 'object', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'The title for the comment type.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + 'slug' => array( + 'description' => __( 'An alphanumeric identifier for the comment type.' ), + 'type' => 'string', + 'context' => array( 'view', 'edit', 'embed' ), + 'readonly' => true, + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the query params for collections. + * + * @since 7.1.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + return array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ); + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 9ec2c3607271d..56869e68e6c40 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -334,6 +334,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-menu-locations-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-users-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-comments-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-comment-types-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-search-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-blocks-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-block-types-controller.php'; From c0d63b6611d3d4e2658c7a3ed50a03595f9ca494 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 15:20:01 -0700 Subject: [PATCH 02/10] Comments: Add tests for the comment types REST API endpoint. Cover the `/wp/v2/comment-types` controller: route registration, the context param, listing public types while excluding non-REST types (`note` and types that opt out via `show_in_rest`), single-type reads, permission checks for the `edit` context, and the item schema. Also assert the `show_in_rest` cascade on `WP_Comment_Type` and add the two new routes to the REST schema route list. See #35214. --- tests/phpunit/tests/comment/types.php | 26 ++ .../rest-comment-types-controller.php | 248 ++++++++++++++++++ .../tests/rest-api/rest-schema-setup.php | 2 + 3 files changed, 276 insertions(+) create mode 100644 tests/phpunit/tests/rest-api/rest-comment-types-controller.php diff --git a/tests/phpunit/tests/comment/types.php b/tests/phpunit/tests/comment/types.php index 54f8e2aa9ad33..bd5d845e445db 100644 --- a/tests/phpunit/tests/comment/types.php +++ b/tests/phpunit/tests/comment/types.php @@ -98,6 +98,32 @@ public function test_register_comment_type_show_ui_should_default_to_value_of_pu $this->assertFalse( get_comment_type_object( 'private_type' )->show_ui ); } + /** + * @ticket 35214 + */ + public function test_register_comment_type_show_in_rest_should_default_to_value_of_public() { + register_comment_type( 'public_type', array( 'public' => true ) ); + $this->assertTrue( get_comment_type_object( 'public_type' )->show_in_rest ); + + register_comment_type( 'private_type', array( 'public' => false ) ); + $this->assertFalse( get_comment_type_object( 'private_type' )->show_in_rest ); + } + + /** + * @ticket 35214 + */ + public function test_register_comment_type_show_in_rest_can_be_overridden() { + register_comment_type( + 'private_but_in_rest', + array( + 'public' => false, + 'show_in_rest' => true, + ) + ); + + $this->assertTrue( get_comment_type_object( 'private_but_in_rest' )->show_in_rest ); + } + /** * @ticket 35214 */ diff --git a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php new file mode 100644 index 0000000000000..db413f4dbdb11 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php @@ -0,0 +1,248 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$subscriber_id ); + } + + /** + * Ensures any comment type registered during a test is cleaned up. + */ + public function tear_down() { + global $wp_comment_types; + + foreach ( array_keys( $wp_comment_types ) as $comment_type ) { + if ( ! $wp_comment_types[ $comment_type ]->_builtin ) { + unset( $wp_comment_types[ $comment_type ] ); + } + } + + parent::tear_down(); + } + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/wp/v2/comment-types', $routes ); + $this->assertArrayHasKey( '/wp/v2/comment-types/(?P[\w-]+)', $routes ); + } + + public function test_context_param() { + // Collection. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSameSets( array( 'view', 'edit', 'embed' ), $data['endpoints'][0]['args']['context']['enum'] ); + // Single. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSameSets( array( 'view', 'edit', 'embed' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + public function test_get_items() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + + $data = $response->get_data(); + $comment_types = get_comment_types( array( 'show_in_rest' => true ), 'objects' ); + $this->assertCount( count( $comment_types ), $data ); + $this->assertSame( $comment_types['comment']->name, $data['comment']['slug'] ); + $this->check_comment_type_obj( 'view', $comment_types['comment'], $data['comment'], $data['comment']['_links'] ); + $this->assertArrayHasKey( 'pingback', $data ); + $this->assertArrayHasKey( 'trackback', $data ); + } + + /** + * The internal `note` type is not exposed (show_in_rest defaults to public, which is false). + */ + public function test_get_items_excludes_non_rest_types() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayNotHasKey( 'note', $data ); + } + + public function test_get_items_includes_registered_custom_type() { + register_comment_type( 'review', array( 'public' => true ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'review', $data ); + } + + public function test_get_items_excludes_custom_type_opted_out_of_rest() { + register_comment_type( + 'review', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayNotHasKey( 'review', $data ); + } + + /** + * @dataProvider data_readable_http_methods + * + * @param string $method HTTP method to use. + */ + public function test_get_items_invalid_permission_for_context( $method ) { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( $method, '/wp/v2/comment-types' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_view', $response, 401 ); + } + + /** + * Data provider intended to provide HTTP method names for testing GET and HEAD requests. + * + * @return array + */ + public static function data_readable_http_methods() { + return array( + 'GET request' => array( 'GET' ), + 'HEAD request' => array( 'HEAD' ), + ); + } + + public function test_get_item() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $this->check_comment_type_object_response( 'view', $response ); + } + + public function test_get_item_invalid_type() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/invalid' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_type_invalid', $response, 404 ); + } + + public function test_get_item_non_rest_type_is_not_readable() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/note' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read_type', $response, 401 ); + } + + public function test_get_item_edit_context_requires_permission() { + wp_set_current_user( self::$subscriber_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + public function test_get_item_edit_context_returns_labels() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->check_comment_type_object_response( 'edit', $response ); + } + + public function test_create_item() { + /** Comment types can't be created */ + $request = new WP_REST_Request( 'POST', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 404, $response->get_status() ); + } + + public function test_update_item() { + /** Comment types can't be updated */ + $request = new WP_REST_Request( 'POST', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 404, $response->get_status() ); + } + + public function test_delete_item() { + /** Comment types can't be deleted */ + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 404, $response->get_status() ); + } + + public function test_prepare_item() { + $obj = get_comment_type_object( 'comment' ); + $endpoint = new WP_REST_Comment_Types_Controller(); + $request = new WP_REST_Request(); + $request->set_param( 'context', 'view' ); + $response = $endpoint->prepare_item_for_response( $obj, $request ); + $this->check_comment_type_obj( 'view', $obj, $response->get_data(), $response->get_links() ); + } + + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + $this->assertCount( 4, $properties ); + $this->assertArrayHasKey( 'name', $properties ); + $this->assertArrayHasKey( 'slug', $properties ); + $this->assertArrayHasKey( 'description', $properties ); + $this->assertArrayHasKey( 'labels', $properties ); + } + + protected function check_comment_type_obj( $context, $comment_type_obj, $data, $links ) { + $this->assertSame( $comment_type_obj->label, $data['name'] ); + $this->assertSame( $comment_type_obj->name, $data['slug'] ); + $this->assertSame( $comment_type_obj->description, $data['description'] ); + + $links = test_rest_expand_compact_links( $links ); + $this->assertSame( rest_url( 'wp/v2/comment-types' ), $links['collection'][0]['href'] ); + $this->assertArrayHasKey( 'https://api.w.org/items', $links ); + + if ( 'edit' === $context ) { + $this->assertSame( (array) $comment_type_obj->labels, (array) $data['labels'] ); + } else { + $this->assertArrayNotHasKey( 'labels', $data ); + } + } + + protected function check_comment_type_object_response( $context, $response, $comment_type = 'comment' ) { + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $obj = get_comment_type_object( $comment_type ); + $this->check_comment_type_obj( $context, $obj, $data, $response->get_links() ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index 89bf2c481c567..c1aa2977e6829 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -138,6 +138,8 @@ public function test_expected_routes_in_schema() { '/wp/v2/users/(?P(?:[\\d]+|me))/application-passwords/(?P[\\w\\-]+)', '/wp/v2/comments', '/wp/v2/comments/(?P[\\d]+)', + '/wp/v2/comment-types', + '/wp/v2/comment-types/(?P[\\w-]+)', '/wp/v2/global-styles/(?P[\/\d+]+)', '/wp/v2/global-styles/(?P[\d]+)/revisions', '/wp/v2/global-styles/(?P[\d]+)/revisions/(?P[\d]+)', From 7daed7e2c489a9ef868785313eda2fb560ee8868 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 20:25:51 -0700 Subject: [PATCH 03/10] Comments: Expand REST comment-types test coverage for HEAD requests. Add happy-path coverage for the controller's dedicated HEAD-request handling in get_items() and prepare_item_for_response(), mirroring the post-types controller (ticket 56481): a collection HEAD request returns 200 without preparing item data, single-item HEAD requests still run the rest_prepare_comment_type filter and allow header injection, and HEAD requests with _fields succeed. Also assert the api.w.org/items link points at the type-filtered comments collection. See #35214. --- .../rest-comment-types-controller.php | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php index db413f4dbdb11..2dfa184cbd342 100644 --- a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php @@ -223,6 +223,107 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'labels', $properties ); } + /** + * The `api.w.org/items` link should point at the type-filtered comments collection. + * + * @ticket 35214 + */ + public function test_get_item_links_to_filtered_comments_collection() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $links = test_rest_expand_compact_links( $response->get_links() ); + + $this->assertArrayHasKey( 'https://api.w.org/items', $links ); + $this->assertSame( + add_query_arg( 'type', 'comment', rest_url( 'wp/v2/comments' ) ), + $links['https://api.w.org/items'][0]['href'] + ); + } + + /** + * A HEAD request to the collection should succeed without preparing any item data. + * + * @ticket 56481 + */ + public function test_get_items_with_head_request_should_not_prepare_comment_types_data() { + $request = new WP_REST_Request( 'HEAD', '/wp/v2/comment-types' ); + $hook_name = 'rest_prepare_comment_type'; + $filter = new MockAction(); + $callback = array( $filter, 'filter' ); + add_filter( $hook_name, $callback ); + $response = rest_get_server()->dispatch( $request ); + remove_filter( $hook_name, $callback ); + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertSame( 0, $filter->get_call_count(), 'The "' . $hook_name . '" filter was called when it should not be for HEAD requests.' ); + $this->assertSame( array(), $response->get_data(), 'The server should not generate a body in response to a HEAD request.' ); + } + + /** + * @dataProvider data_readable_http_methods + * @ticket 56481 + * + * @param string $method The HTTP method to use. + */ + public function test_get_item_should_allow_adding_headers_via_filter( $method ) { + $request = new WP_REST_Request( $method, '/wp/v2/comment-types/comment' ); + + $hook_name = 'rest_prepare_comment_type'; + $filter = new MockAction(); + $callback = array( $filter, 'filter' ); + add_filter( $hook_name, $callback ); + $header_filter = new class() { + public static function add_custom_header( $response ) { + $response->header( 'X-Test-Header', 'Test' ); + + return $response; + } + }; + add_filter( $hook_name, array( $header_filter, 'add_custom_header' ) ); + $response = rest_get_server()->dispatch( $request ); + remove_filter( $hook_name, $callback ); + remove_filter( $hook_name, array( $header_filter, 'add_custom_header' ) ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + $this->assertSame( 1, $filter->get_call_count(), 'The "' . $hook_name . '" filter should be called once.' ); + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'X-Test-Header', $headers, 'The "X-Test-Header" header should be present in the response.' ); + $this->assertSame( 'Test', $headers['X-Test-Header'], 'The "X-Test-Header" header value should be equal to "Test".' ); + if ( 'HEAD' !== $method ) { + return null; + } + $this->assertSame( array(), $response->get_data(), 'The server should not generate a body in response to a HEAD request.' ); + } + + /** + * @dataProvider data_head_request_with_specified_fields_returns_success_response + * @ticket 56481 + * + * @param string $path The path to test. + */ + public function test_head_request_with_specified_fields_returns_success_response( $path ) { + $request = new WP_REST_Request( 'HEAD', $path ); + $request->set_param( '_fields', 'slug' ); + $server = rest_get_server(); + $response = $server->dispatch( $request ); + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $server, $request ); + remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 ); + + $this->assertSame( 200, $response->get_status(), 'The response status should be 200.' ); + } + + /** + * Data provider intended to provide paths for testing HEAD requests. + * + * @return array + */ + public static function data_head_request_with_specified_fields_returns_success_response() { + return array( + 'get_item request' => array( '/wp/v2/comment-types/comment' ), + 'get_items request' => array( '/wp/v2/comment-types' ), + ); + } + protected function check_comment_type_obj( $context, $comment_type_obj, $data, $links ) { $this->assertSame( $comment_type_obj->label, $data['name'] ); $this->assertSame( $comment_type_obj->name, $data['slug'] ); From 274dcb3772aacb5b1da73032aad6fb84a08c161a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 10:15:43 -0700 Subject: [PATCH 04/10] Comments: Clarify scope and tighten the comment types REST controller. - Document exactly what 'show_in_rest' gates (the read-only discovery endpoint) in the property docblock and the register_comment_type() args reference, including that it does not affect comment readability via /wp/v2/comments, and realign the args hash. - Document the deliberate divergence from WP_Post_Type in defaulting 'show_in_rest' from 'public': the false default there is pure back-compat for pre-REST post types, which a new registry lacks. - Reuse the existing 'not allowed to edit comments' string instead of introducing a new translator-facing string. - Correct the get_items() summary (it retrieves REST-exposed types, not public ones) and mark the seam where per-type capability checks land once comment type capabilities exist. - Tests: pin the anonymous 401-before-type-resolution ordering (a deliberate divergence from the post types controller's 404), cover HEAD for invalid types, _fields-limited responses, additional fields registered for 'comment-type', edit-context labels for all exposed types with 'note' still hidden from moderators, and add missing @ticket tags. Drop the hand-rolled tear_down() now handled by the test framework. --- src/wp-includes/comment.php | 31 ++-- ...class-wp-rest-comment-types-controller.php | 13 +- tests/phpunit/tests/comment/types.php | 6 +- .../rest-comment-types-controller.php | 163 ++++++++++++++++-- 4 files changed, 181 insertions(+), 32 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b01a5c3c4bd8e..85303be94bead 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -361,19 +361,24 @@ function create_initial_comment_types() { * @param array|string $args { * Optional. Array or string of arguments for registering a comment type. Default empty array. * - * @type string $label Name of the comment type. Usually plural. - * Default is the value of $labels['name']. - * @type string[] $labels An array of labels for this comment type. If not set, the - * default comment labels are used. See get_comment_type_labels() - * for a full list of supported labels. - * @type string $description A short descriptive summary of what the comment type is. - * Default empty. - * @type bool $public Whether the comment type is intended for use publicly either via - * the admin interface or by front-end users. Core does not - * currently act on this argument. Default true. - * @type bool $internal Whether the comment type is for internal use only. Core does not - * currently consult this flag; it is intended to drive default - * query exclusions in the future. Default false. + * @type string $label Name of the comment type. Usually plural. + * Default is the value of $labels['name']. + * @type string[] $labels An array of labels for this comment type. If not set, the + * default comment labels are used. See get_comment_type_labels() + * for a full list of supported labels. + * @type string $description A short descriptive summary of what the comment type is. + * Default empty. + * @type bool $public Whether the comment type is intended for use publicly either via + * the admin interface or by front-end users. Core does not + * currently act on this argument. Default true. + * @type bool $internal Whether the comment type is for internal use only. Core does not + * currently consult this flag; it is intended to drive default + * query exclusions in the future. Default false. + * @type bool $show_in_rest Whether to expose this comment type in the REST API comment types + * discovery endpoint (`/wp/v2/comment-types`). Exposing a type + * reveals its name, slug, description, and labels only; it does not + * make comments of this type readable or queryable via + * `/wp/v2/comments`. Default is the value of $public. * } * @return WP_Comment_Type|WP_Error The registered comment type object on success, * WP_Error object on failure. diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php index f5913d1d9242a..0ffee5f97a50c 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php @@ -84,7 +84,7 @@ public function get_items_permissions_check( $request ) { if ( 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( 'rest_cannot_view', - __( 'Sorry, you are not allowed to manage comments.' ), + __( 'Sorry, you are not allowed to edit comments.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -93,7 +93,7 @@ public function get_items_permissions_check( $request ) { } /** - * Retrieves all public comment types. + * Retrieves comment types exposed in the REST API. * * @since 7.1.0 * @@ -109,6 +109,13 @@ public function get_items( $request ) { $data = array(); $types = get_comment_types( array( 'show_in_rest' => true ), 'objects' ); + /* + * Once per-comment-type capabilities exist, edit-context collection requests + * should skip types the current user cannot moderate here, rather than + * relying solely on the global 'moderate_comments' gate in + * get_items_permissions_check() (matching how the post types controller + * hides non-editable types from edit-context collections). + */ foreach ( $types as $type ) { $comment_type = $this->prepare_item_for_response( $type, $request ); $data[ $type->name ] = $this->prepare_response_for_collection( $comment_type ); @@ -129,7 +136,7 @@ public function get_item_permissions_check( $request ) { if ( 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( 'rest_forbidden_context', - __( 'Sorry, you are not allowed to manage comments.' ), + __( 'Sorry, you are not allowed to edit comments.' ), array( 'status' => rest_authorization_required_code() ) ); } diff --git a/tests/phpunit/tests/comment/types.php b/tests/phpunit/tests/comment/types.php index 12345e010c96e..b07ff5cc5091c 100644 --- a/tests/phpunit/tests/comment/types.php +++ b/tests/phpunit/tests/comment/types.php @@ -78,7 +78,7 @@ public function test_register_comment_type_with_empty_name() { /** * @ticket 35214 * - * @covers ::create_initial_comment_types + * @covers ::register_comment_type */ public function test_register_comment_type_show_in_rest_should_default_to_value_of_public() { register_comment_type( 'public_type', array( 'public' => true ) ); @@ -90,6 +90,8 @@ public function test_register_comment_type_show_in_rest_should_default_to_value_ /** * @ticket 35214 + * + * @covers ::register_comment_type */ public function test_register_comment_type_show_in_rest_can_be_overridden() { register_comment_type( @@ -105,6 +107,8 @@ public function test_register_comment_type_show_in_rest_can_be_overridden() { /** * @ticket 35214 + * + * @covers ::create_initial_comment_types */ public function test_built_in_comment_types_are_registered() { $this->assertTrue( comment_type_exists( 'comment' ) ); diff --git a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php index 2dfa184cbd342..000fea0044698 100644 --- a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php @@ -37,26 +37,17 @@ public static function wpTearDownAfterClass() { } /** - * Ensures any comment type registered during a test is cleaned up. + * @ticket 35214 */ - public function tear_down() { - global $wp_comment_types; - - foreach ( array_keys( $wp_comment_types ) as $comment_type ) { - if ( ! $wp_comment_types[ $comment_type ]->_builtin ) { - unset( $wp_comment_types[ $comment_type ] ); - } - } - - parent::tear_down(); - } - public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/comment-types', $routes ); $this->assertArrayHasKey( '/wp/v2/comment-types/(?P[\w-]+)', $routes ); } + /** + * @ticket 35214 + */ public function test_context_param() { // Collection. $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comment-types' ); @@ -72,6 +63,9 @@ public function test_context_param() { $this->assertSameSets( array( 'view', 'edit', 'embed' ), $data['endpoints'][0]['args']['context']['enum'] ); } + /** + * @ticket 35214 + */ public function test_get_items() { $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); $response = rest_get_server()->dispatch( $request ); @@ -87,6 +81,8 @@ public function test_get_items() { /** * The internal `note` type is not exposed (show_in_rest defaults to public, which is false). + * + * @ticket 35214 */ public function test_get_items_excludes_non_rest_types() { $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); @@ -96,6 +92,9 @@ public function test_get_items_excludes_non_rest_types() { $this->assertArrayNotHasKey( 'note', $data ); } + /** + * @ticket 35214 + */ public function test_get_items_includes_registered_custom_type() { register_comment_type( 'review', array( 'public' => true ) ); @@ -106,6 +105,9 @@ public function test_get_items_includes_registered_custom_type() { $this->assertArrayHasKey( 'review', $data ); } + /** + * @ticket 35214 + */ public function test_get_items_excludes_custom_type_opted_out_of_rest() { register_comment_type( 'review', @@ -123,6 +125,8 @@ public function test_get_items_excludes_custom_type_opted_out_of_rest() { } /** + * @ticket 35214 + * * @dataProvider data_readable_http_methods * * @param string $method HTTP method to use. @@ -147,24 +151,40 @@ public static function data_readable_http_methods() { ); } + /** + * @ticket 35214 + */ public function test_get_item() { $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); $response = rest_get_server()->dispatch( $request ); $this->check_comment_type_object_response( 'view', $response ); } - public function test_get_item_invalid_type() { - $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/invalid' ); + /** + * @ticket 35214 + * + * @dataProvider data_readable_http_methods + * + * @param string $method HTTP method to use. + */ + public function test_get_item_invalid_type( $method ) { + $request = new WP_REST_Request( $method, '/wp/v2/comment-types/invalid' ); $response = rest_get_server()->dispatch( $request ); $this->assertErrorResponse( 'rest_type_invalid', $response, 404 ); } + /** + * @ticket 35214 + */ public function test_get_item_non_rest_type_is_not_readable() { $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/note' ); $response = rest_get_server()->dispatch( $request ); $this->assertErrorResponse( 'rest_cannot_read_type', $response, 401 ); } + /** + * @ticket 35214 + */ public function test_get_item_edit_context_requires_permission() { wp_set_current_user( self::$subscriber_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); @@ -173,6 +193,104 @@ public function test_get_item_edit_context_requires_permission() { $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); } + /** + * An anonymous edit-context request is rejected with 401 before the type is resolved. + * + * This deliberately diverges from the post types controller, which resolves the type + * first and returns 404 for unknown types: here the permission callback runs before + * the route callback, so authentication is always demanded first. + * + * @ticket 35214 + */ + public function test_get_item_edit_context_as_anonymous_returns_401() { + wp_set_current_user( 0 ); + + // A known type. + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); + + // An unknown type: still 401, not 404. + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/unknown' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_forbidden_context', $response, 401 ); + } + + /** + * @ticket 35214 + */ + public function test_get_item_with_fields_limits_response_keys() { + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $request->set_param( '_fields', 'slug' ); + $server = rest_get_server(); + $response = $server->dispatch( $request ); + add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 ); + $response = apply_filters( 'rest_post_dispatch', $response, $server, $request ); + remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 ); + + $this->assertSame( array( 'slug' ), array_keys( $response->get_data() ) ); + } + + /** + * @ticket 35214 + */ + public function test_additional_field_registered_for_comment_type_is_included() { + register_rest_field( + 'comment-type', + 'my_custom_field', + array( + 'get_callback' => static function () { + return 'custom value'; + }, + 'schema' => array( 'type' => 'string' ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'my_custom_field', $data ); + $this->assertSame( 'custom value', $data['my_custom_field'] ); + + global $wp_rest_additional_fields; + $wp_rest_additional_fields = array(); + } + + /** + * In the edit context, labels are returned for every exposed type, and the internal + * 'note' type stays hidden even from users who can moderate comments. + * + * @ticket 35214 + */ + public function test_get_items_edit_context_returns_labels_and_still_excludes_note() { + wp_set_current_user( self::$admin_id ); + register_comment_type( + 'review', + array( + 'label' => 'Reviews', + 'public' => true, + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'review', $data ); + $this->assertArrayNotHasKey( 'note', $data ); + + foreach ( $data as $slug => $item ) { + $this->assertArrayHasKey( 'labels', $item, "Labels missing for '{$slug}' in edit context." ); + } + } + + /** + * @ticket 35214 + */ public function test_get_item_edit_context_returns_labels() { wp_set_current_user( self::$admin_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/comment' ); @@ -181,6 +299,9 @@ public function test_get_item_edit_context_returns_labels() { $this->check_comment_type_object_response( 'edit', $response ); } + /** + * @ticket 35214 + */ public function test_create_item() { /** Comment types can't be created */ $request = new WP_REST_Request( 'POST', '/wp/v2/comment-types' ); @@ -188,6 +309,9 @@ public function test_create_item() { $this->assertSame( 404, $response->get_status() ); } + /** + * @ticket 35214 + */ public function test_update_item() { /** Comment types can't be updated */ $request = new WP_REST_Request( 'POST', '/wp/v2/comment-types/comment' ); @@ -195,6 +319,9 @@ public function test_update_item() { $this->assertSame( 404, $response->get_status() ); } + /** + * @ticket 35214 + */ public function test_delete_item() { /** Comment types can't be deleted */ $request = new WP_REST_Request( 'DELETE', '/wp/v2/comment-types/comment' ); @@ -202,6 +329,9 @@ public function test_delete_item() { $this->assertSame( 404, $response->get_status() ); } + /** + * @ticket 35214 + */ public function test_prepare_item() { $obj = get_comment_type_object( 'comment' ); $endpoint = new WP_REST_Comment_Types_Controller(); @@ -211,6 +341,9 @@ public function test_prepare_item() { $this->check_comment_type_obj( 'view', $obj, $response->get_data(), $response->get_links() ); } + /** + * @ticket 35214 + */ public function test_get_item_schema() { $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/comment-types' ); $response = rest_get_server()->dispatch( $request ); From e06ca77e07e4642194554278623fbec3e001ee95 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 10:17:09 -0700 Subject: [PATCH 05/10] Coding Standards: Remove a double blank line in WP_Comment_Type::set_props(). --- src/wp-includes/class-wp-comment-type.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 75115eca712c9..d626edf9e05a1 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -217,7 +217,6 @@ public function set_props( $args ) { $args['show_in_rest'] = $args['public']; } - $args['name'] = $this->name; foreach ( $args as $property_name => $property_value ) { From 22c397588d3efda4e2daeebd67fc41a567668c8a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 10:17:09 -0700 Subject: [PATCH 06/10] REST API: Regenerate the wp-api.js fixture for the comment-types routes. tests/qunit/fixtures/wp-api-generated.js is written by test_build_wp_api_client_fixtures and diff-checked in CI; the new /wp/v2/comment-types routes must be reflected in the committed copy. --- tests/qunit/fixtures/wp-api-generated.js | 72 +++++++++++++++++++++--- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..77dff892703c1 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -22,13 +22,7 @@ mockedApiResponse.Schema = { "wp-block-editor/v1", "wp-abilities/v1" ], - "authentication": { - "application-passwords": { - "endpoints": { - "authorization": "http://example.org/wp-admin/authorize-application.php" - } - } - }, + "authentication": [], "routes": { "/": { "namespace": "", @@ -10823,6 +10817,70 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/comment-types": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/comment-types" + } + ] + } + }, + "/wp/v2/comment-types/(?P[\\w-]+)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "type": { + "description": "An alphanumeric identifier for the comment type.", + "type": "string", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + } + } + } + ] + }, "/wp/v2/search": { "namespace": "wp/v2", "methods": [ From af523dff017f8c9e40c304f45cfe002185bfa49e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 16:52:10 -0700 Subject: [PATCH 07/10] REST API: Restore the application passwords block in the wp-api.js fixture. The fixture was regenerated in an environment without SSL and outside a local environment type, so wp_is_application_passwords_available() returned false and the authentication object came out as an empty array. That hunk has nothing to do with the comment types routes, and it fails the "version-controlled files are not modified" check on any repo that runs it, because a canonical regeneration puts the object back. Restore the block. The fixture is now identical to trunk apart from the two new comment-types route entries, which were correct already. See #35214. --- tests/qunit/fixtures/wp-api-generated.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 27d04ee33e4e6..f2162c6038aa1 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -22,7 +22,13 @@ mockedApiResponse.Schema = { "wp-block-editor/v1", "wp-abilities/v1" ], - "authentication": [], + "authentication": { + "application-passwords": { + "endpoints": { + "authorization": "http://example.org/wp-admin/authorize-application.php" + } + } + }, "routes": { "/": { "namespace": "", From 0bd58bbfafc289df938f971f75f1b3672ea9df6f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 16:54:14 -0700 Subject: [PATCH 08/10] REST API: Settle the comment types endpoint's remaining divergences. Three things the endpoint did without saying why. The advertised 'items' link points at the comments collection filtered by type, but that endpoint's `type` parameter is protected: a caller without `edit_posts` may only ask for 'comment', so for every other type the link answers 401 to an anonymous client. Keeping the link is the right call - what a caller may query belongs to the comments endpoint, and the answer changes once per-type capabilities land - but the next reader should not have to rediscover it. The `description` field was exposed in embed context, where the post types controller it mirrors exposes only view and edit. Embed is conventionally identity fields; there is no reason for the divergence, so drop it. Two behaviors also had no test: a non-public type that sets show_in_rest is listed and readable, which is the direction the exclusion tests miss, and the collection's edit context answers 403 to an authenticated caller without the capability, matching the single-item route. See #35214. --- ...class-wp-rest-comment-types-controller.php | 10 +++- .../rest-comment-types-controller.php | 49 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php index 0ffee5f97a50c..40ac95b130680 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php @@ -242,6 +242,14 @@ public function prepare_item_for_response( $item, $request ) { /** * Prepares links for the request. * + * Note that the 'items' link is only followable by a user who can pass the comments + * endpoint's `type` parameter. That parameter is protected: callers without + * `edit_posts` may only request the 'comment' type, so for every other type the link + * answers 401 to an anonymous client. The link is still advertised, because what a + * caller may query is a question for the comments endpoint rather than something + * discovery should second-guess, and because the answer changes once per-type + * capabilities land. + * * @since 7.1.0 * * @param WP_Comment_Type $comment_type The comment type. @@ -278,7 +286,7 @@ public function get_item_schema() { 'description' => array( 'description' => __( 'A human-readable description of the comment type.' ), 'type' => 'string', - 'context' => array( 'view', 'edit', 'embed' ), + 'context' => array( 'view', 'edit' ), 'readonly' => true, ), 'labels' => array( diff --git a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php index 000fea0044698..c0f088a3c2474 100644 --- a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php @@ -473,6 +473,55 @@ protected function check_comment_type_obj( $context, $comment_type_obj, $data, $ } } + /** + * The subscriber case for the collection, matching the single-item route: authenticated + * but without the capability is a 403, where anonymous is a 401. + * + * @ticket 35214 + */ + public function test_get_items_edit_context_requires_permission() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_view', $response, 403 ); + } + + /** + * show_in_rest only defaults from public, so a non-public type that opts in explicitly + * is discoverable. This is the direction the exclusion tests do not cover. + * + * @ticket 35214 + */ + public function test_non_public_type_with_show_in_rest_is_exposed() { + register_comment_type( + 'wp_tests_hidden', + array( + 'label' => 'Hidden', + 'public' => false, + 'show_in_rest' => true, + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( + 'wp_tests_hidden', + $response->get_data(), + 'A non-public type that opts into REST should be listed.' + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comment-types/wp_tests_hidden' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'It should also be readable as a single item.' ); + $this->assertSame( 'wp_tests_hidden', $response->get_data()['slug'] ); + } + protected function check_comment_type_object_response( $context, $response, $comment_type = 'comment' ) { $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); From c118907bf6d104393f681b083295144741fede48 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 23 Aug 2026 08:26:55 -0700 Subject: [PATCH 09/10] REST API: Stamp the comment types endpoint for 7.2.0 and tidy the controller. The branch was written while trunk was 7.1-alpha; trunk is now 7.2-alpha, so the controller's and show_in_rest property's @since tags would have credited the wrong release - and left WP_Comment_Type internally contradictory, with the property claiming to predate its class. Also trim a speculative design note about future per-type capabilities out of get_items() (that discussion belongs on the ticket), and move two test methods above the protected helpers to restore the tests-then-helpers layout used by the rest of the file. See #35214. --- src/wp-includes/class-wp-comment-type.php | 2 +- ...class-wp-rest-comment-types-controller.php | 34 ++++++++----------- .../rest-comment-types-controller.php | 32 ++++++++--------- 3 files changed, 31 insertions(+), 37 deletions(-) diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 2766c802574ee..fb5832a6e2a7a 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -97,7 +97,7 @@ final class WP_Comment_Type { * labels). It does not affect whether comments of this type are readable * or queryable via `/wp/v2/comments`. Default is the value of $public. * - * @since 7.1.0 + * @since 7.2.0 * @var bool */ public $show_in_rest; diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php index 40ac95b130680..adaabe5018497 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comment-types-controller.php @@ -4,13 +4,13 @@ * * @package WordPress * @subpackage REST_API - * @since 7.1.0 + * @since 7.2.0 */ /** * Core class to access comment types via the REST API. * - * @since 7.1.0 + * @since 7.2.0 * * @see WP_REST_Controller */ @@ -19,7 +19,7 @@ class WP_REST_Comment_Types_Controller extends WP_REST_Controller { /** * Constructor. * - * @since 7.1.0 + * @since 7.2.0 */ public function __construct() { $this->namespace = 'wp/v2'; @@ -29,7 +29,7 @@ public function __construct() { /** * Registers the routes for comment types. * - * @since 7.1.0 + * @since 7.2.0 * * @see register_rest_route() */ @@ -75,7 +75,7 @@ public function register_routes() { /** * Checks whether a given request has permission to read comment types. * - * @since 7.1.0 + * @since 7.2.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access, WP_Error object otherwise. @@ -95,7 +95,7 @@ public function get_items_permissions_check( $request ) { /** * Retrieves comment types exposed in the REST API. * - * @since 7.1.0 + * @since 7.2.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. @@ -109,13 +109,7 @@ public function get_items( $request ) { $data = array(); $types = get_comment_types( array( 'show_in_rest' => true ), 'objects' ); - /* - * Once per-comment-type capabilities exist, edit-context collection requests - * should skip types the current user cannot moderate here, rather than - * relying solely on the global 'moderate_comments' gate in - * get_items_permissions_check() (matching how the post types controller - * hides non-editable types from edit-context collections). - */ + // Edit-context requests are gated globally by get_items_permissions_check(). foreach ( $types as $type ) { $comment_type = $this->prepare_item_for_response( $type, $request ); $data[ $type->name ] = $this->prepare_response_for_collection( $comment_type ); @@ -127,7 +121,7 @@ public function get_items( $request ) { /** * Checks if a given request has access to read a comment type. * - * @since 7.1.0 + * @since 7.2.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access for the item, WP_Error object otherwise. @@ -147,7 +141,7 @@ public function get_item_permissions_check( $request ) { /** * Retrieves a specific comment type. * - * @since 7.1.0 + * @since 7.2.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. @@ -179,7 +173,7 @@ public function get_item( $request ) { /** * Prepares a comment type object for serialization. * - * @since 7.1.0 + * @since 7.2.0 * * @param WP_Comment_Type $item Comment type object. * @param WP_REST_Request $request Full details about the request. @@ -230,7 +224,7 @@ public function prepare_item_for_response( $item, $request ) { * * Allows modification of the comment type data right before it is returned. * - * @since 7.1.0 + * @since 7.2.0 * * @param WP_REST_Response $response The response object. * @param WP_Comment_Type $comment_type The original comment type object. @@ -250,7 +244,7 @@ public function prepare_item_for_response( $item, $request ) { * discovery should second-guess, and because the answer changes once per-type * capabilities land. * - * @since 7.1.0 + * @since 7.2.0 * * @param WP_Comment_Type $comment_type The comment type. * @return array Links for the given comment type. @@ -269,7 +263,7 @@ protected function prepare_links( $comment_type ) { /** * Retrieves the comment type's schema, conforming to JSON Schema. * - * @since 7.1.0 + * @since 7.2.0 * * @return array Item schema data. */ @@ -318,7 +312,7 @@ public function get_item_schema() { /** * Retrieves the query params for collections. * - * @since 7.1.0 + * @since 7.2.0 * * @return array Collection parameters. */ diff --git a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php index c0f088a3c2474..2def39bc4a805 100644 --- a/tests/phpunit/tests/rest-api/rest-comment-types-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comment-types-controller.php @@ -457,22 +457,6 @@ public static function data_head_request_with_specified_fields_returns_success_r ); } - protected function check_comment_type_obj( $context, $comment_type_obj, $data, $links ) { - $this->assertSame( $comment_type_obj->label, $data['name'] ); - $this->assertSame( $comment_type_obj->name, $data['slug'] ); - $this->assertSame( $comment_type_obj->description, $data['description'] ); - - $links = test_rest_expand_compact_links( $links ); - $this->assertSame( rest_url( 'wp/v2/comment-types' ), $links['collection'][0]['href'] ); - $this->assertArrayHasKey( 'https://api.w.org/items', $links ); - - if ( 'edit' === $context ) { - $this->assertSame( (array) $comment_type_obj->labels, (array) $data['labels'] ); - } else { - $this->assertArrayNotHasKey( 'labels', $data ); - } - } - /** * The subscriber case for the collection, matching the single-item route: authenticated * but without the capability is a 403, where anonymous is a 401. @@ -522,6 +506,22 @@ public function test_non_public_type_with_show_in_rest_is_exposed() { $this->assertSame( 'wp_tests_hidden', $response->get_data()['slug'] ); } + protected function check_comment_type_obj( $context, $comment_type_obj, $data, $links ) { + $this->assertSame( $comment_type_obj->label, $data['name'] ); + $this->assertSame( $comment_type_obj->name, $data['slug'] ); + $this->assertSame( $comment_type_obj->description, $data['description'] ); + + $links = test_rest_expand_compact_links( $links ); + $this->assertSame( rest_url( 'wp/v2/comment-types' ), $links['collection'][0]['href'] ); + $this->assertArrayHasKey( 'https://api.w.org/items', $links ); + + if ( 'edit' === $context ) { + $this->assertSame( (array) $comment_type_obj->labels, (array) $data['labels'] ); + } else { + $this->assertArrayNotHasKey( 'labels', $data ); + } + } + protected function check_comment_type_object_response( $context, $response, $comment_type = 'comment' ) { $this->assertSame( 200, $response->get_status() ); $data = $response->get_data(); From 82a303659ba68ed95d7d71ddfe4825cea7940294 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 23 Aug 2026 08:34:58 -0700 Subject: [PATCH 10/10] Docs: Note that internal does not keep a comment type out of REST discovery. show_in_rest deliberately cascades from public alone, so a type registered with internal => true but left public still defaults to being listed by the discovery endpoint. That combination is most likely an oversight on the registrant's part, and the argument contract should say plainly how to opt out rather than leaving the cascade to be inferred: pass show_in_rest => false or public => false, as the built-in note type does. See #35214. --- src/wp-includes/comment.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 11eb3db04f61c..6afd73f17b963 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -418,7 +418,10 @@ function create_initial_comment_types() { * * An argument never implies another argument, with one deliberate exception: * `show_in_rest` defaults from `public`, since a type meant to be seen is a type worth - * discovering. + * discovering. Note that `internal` is not part of that cascade: a type registered with + * `internal => true` but left `public` still defaults to REST-discoverable. A type that + * should stay out of discovery must pass `show_in_rest => false` (or `public => false`), + * as the built-in 'note' type does. * * Registrations live in a per-process global. Like post types, they are not scoped to a * site on multisite: a type registered by one site's plugins is visible after