From 90997f6724d24e54ccbdcc255f0e129e41167060 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 13 Aug 2026 15:57:35 -0700 Subject: [PATCH 1/5] REST API: Add a dedicated notes controller. Serve editorial notes from wp/v2/notes instead of asking clients to filter wp/v2/comments by type. The collection returns threads with their replies nested and prepared in the same context, so pagination never orphans a reply and context=edit carries content.raw all the way down, which _embed cannot do. Opens up check_post_type_supports_notes() so the subclass can reuse it. --- src/wp-includes/rest-api.php | 4 + .../class-wp-rest-comments-controller.php | 2 +- .../class-wp-rest-notes-controller.php | 457 +++++++++++++++ src/wp-settings.php | 1 + .../tests/rest-api/rest-notes-controller.php | 551 ++++++++++++++++++ .../tests/rest-api/rest-schema-setup.php | 2 + 6 files changed, 1016 insertions(+), 1 deletion(-) create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php create mode 100644 tests/phpunit/tests/rest-api/rest-notes-controller.php diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index d54cee18c5b39..6c60ea97e37c2 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(); + // Notes. + $controller = new WP_REST_Notes_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-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index d14aefb1f6308..f2c7634a8906c 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -2047,7 +2047,7 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { * @param string $post_type Post type name. * @return bool True if post type supports notes, false otherwise. */ - private function check_post_type_supports_notes( $post_type ) { + protected function check_post_type_supports_notes( $post_type ) { $supports = get_all_post_type_supports( $post_type ); if ( ! isset( $supports['editor'] ) ) { diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php new file mode 100644 index 0000000000000..157121d133b77 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php @@ -0,0 +1,457 @@ +rest_base = 'notes'; + } + + /** + * Checks if a given request has access to read notes. + * + * Notes live alongside a post and are visible to everyone who can edit that + * post, so the check collapses to `edit_post` for every requested post. + * + * @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. + */ + public function get_items_permissions_check( $request ) { + if ( ! is_user_logged_in() ) { + return new WP_Error( + 'rest_notes_not_logged_in', + __( 'Sorry, you are not allowed to read notes.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $post_ids = array_filter( array_map( 'absint', (array) $request['post'] ) ); + + if ( empty( $post_ids ) ) { + return new WP_Error( + 'rest_notes_missing_post', + __( 'Notes must be requested for at least one post.' ), + array( 'status' => 400 ) + ); + } + + foreach ( $post_ids as $post_id ) { + $check = $this->check_note_post_permission( $post_id ); + + if ( is_wp_error( $check ) ) { + return $check; + } + } + + return true; + } + + /** + * Retrieves a list of note threads. + * + * @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. + */ + public function get_items( $request ) { + /* + * `type` and `parent` are not exposed as collection parameters, so the + * comments controller never maps them onto the comment query. They are + * pinned here instead: without an explicit `type`, WP_Comment_Query + * excludes notes outright. + */ + $scope_to_threads = static function ( $prepared_args ) { + $prepared_args['type'] = 'note'; + $prepared_args['parent'] = 0; + + return $prepared_args; + }; + + add_filter( 'rest_comment_query', $scope_to_threads, PHP_INT_MAX ); + + try { + $response = parent::get_items( $request ); + } finally { + remove_filter( 'rest_comment_query', $scope_to_threads, PHP_INT_MAX ); + } + + if ( is_wp_error( $response ) ) { + return $response; + } + + return $this->attach_replies( $response, $request ); + } + + /** + * Retrieves a single note thread. + * + * @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. + */ + public function get_item( $request ) { + $response = parent::get_item( $request ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + return $this->attach_replies( $response, $request ); + } + + /** + * Creates a note. + * + * @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. + */ + public function create_item( $request ) { + $request['type'] = 'note'; + + return parent::create_item( $request ); + } + + /** + * Checks if a given request has access to create a note. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise. + */ + public function create_item_permissions_check( $request ) { + $request['type'] = 'note'; + + return parent::create_item_permissions_check( $request ); + } + + /** + * Gets the note, if the ID is valid. + * + * Guards the single-note routes so `wp/v2/notes/` cannot be used to read + * or edit an ordinary comment that happens to share the ID space. + * + * @since 7.2.0 + * + * @param int $id Supplied ID. + * @return WP_Comment|WP_Error Comment object if the ID is a note, WP_Error object otherwise. + */ + protected function get_comment( $id ) { + $comment = parent::get_comment( $id ); + + if ( is_wp_error( $comment ) ) { + return $comment; + } + + if ( 'note' !== $comment->comment_type ) { + return new WP_Error( + 'rest_note_invalid_id', + __( 'Invalid note ID.' ), + array( 'status' => 404 ) + ); + } + + return $comment; + } + + /** + * Prepares links for the request. + * + * Drops the `children` link the comments controller builds. Replies already + * travel inside the response, and assembling that link costs a `COUNT` query + * per note, the single most expensive part of rendering a large thread list. + * + * @since 7.2.0 + * + * @param WP_Comment $comment Comment object. + * @return array Links for the given note. + */ + protected function prepare_links( $comment ) { + $links = parent::prepare_links( $comment ); + + unset( $links['children'] ); + + return $links; + } + + /** + * Retrieves the note's schema, conforming to JSON Schema. + * + * @since 7.2.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + /* + * The schema title stays `comment`: a note is a comment record, so + * comment meta and anything registered through + * `register_rest_field( 'comment', ... )` must keep applying here. + */ + $schema = parent::get_item_schema(); + + /* + * Notes are authored by logged-in users, so the anonymous commenter + * identity fields never carry a value, and a note has no permalink. + */ + unset( + $schema['properties']['author_email'], + $schema['properties']['author_ip'], + $schema['properties']['author_url'], + $schema['properties']['author_user_agent'], + $schema['properties']['link'] + ); + + $schema['properties']['reply_count'] = array( + 'description' => __( 'The number of replies in the thread.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ); + + $schema['properties']['replies'] = array( + 'description' => __( 'The replies in the thread, oldest first.' ), + 'type' => 'array', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'items' => array( + 'type' => 'object', + ), + ); + + return $schema; + } + + /** + * Retrieves the query params for the notes collection. + * + * @since 7.2.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + // A note is always read by someone editing the post it belongs to. + $query_params['context']['default'] = 'edit'; + + /* + * The collection is threads-only and always `note` typed, so the + * parameters that would let a client ask for anything else are not + * exposed. + */ + unset( + $query_params['type'], + $query_params['parent'], + $query_params['parent_exclude'], + $query_params['author_email'], + $query_params['password'] + ); + + /* + * Dropping the default is what makes `required` bite: an empty array + * would otherwise satisfy the presence check. + */ + unset( $query_params['post']['default'] ); + $query_params['post']['required'] = true; + + /* + * Open and resolved notes are both interesting, so `approve` is not a + * useful default for a collection that models a review workflow. + */ + $query_params['status']['default'] = 'all'; + + return $query_params; + } + + /** + * Checks that a post exists, supports notes, and is editable by the current user. + * + * @since 7.2.0 + * + * @param int $post_id Post ID. + * @return true|WP_Error True when notes on the post are readable, WP_Error object otherwise. + */ + protected function check_note_post_permission( $post_id ) { + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'rest_post_invalid_id', + __( 'Invalid post ID.' ), + array( 'status' => 404 ) + ); + } + + if ( ! $this->check_post_type_supports_notes( $post->post_type ) ) { + return new WP_Error( + 'rest_note_not_supported_post_type', + __( 'Sorry, this post type does not support notes.' ), + array( 'status' => 403 ) + ); + } + + if ( ! current_user_can( 'edit_post', $post->ID ) ) { + return new WP_Error( + 'rest_cannot_read_notes', + __( 'Sorry, you are not allowed to read notes for this post.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Nests each thread's replies into the prepared response. + * + * Replies for the whole page are fetched in one query, so the cost does not + * grow with the number of threads on the page. + * + * @since 7.2.0 + * + * @param WP_REST_Response $response Prepared response holding one thread or a page of them. + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response Response with `replies` and `reply_count` filled in. + */ + protected function attach_replies( $response, $request ) { + if ( $request->is_method( 'HEAD' ) ) { + return $response; + } + + $fields = $this->get_fields_for_response( $request ); + $want_replies = rest_is_field_included( 'replies', $fields ); + $want_count = rest_is_field_included( 'reply_count', $fields ); + + if ( ! $want_replies && ! $want_count ) { + return $response; + } + + $data = $response->get_data(); + $is_single = ! wp_is_numeric_array( $data ); + $threads = $is_single ? array( $data ) : $data; + + $thread_ids = array(); + foreach ( $threads as $thread ) { + /* + * `_fields` can omit the ID, and without it there is nothing to + * hang replies off of. + */ + if ( isset( $thread['id'] ) ) { + $thread_ids[] = (int) $thread['id']; + } + } + + if ( empty( $thread_ids ) ) { + return $response; + } + + $replies_by_parent = $this->get_replies( $thread_ids, $request ); + + foreach ( $threads as $index => $thread ) { + if ( ! isset( $thread['id'] ) ) { + continue; + } + + $replies = isset( $replies_by_parent[ $thread['id'] ] ) ? $replies_by_parent[ $thread['id'] ] : array(); + + if ( $want_replies ) { + $threads[ $index ]['replies'] = $replies; + } + + if ( $want_count ) { + $threads[ $index ]['reply_count'] = count( $replies ); + } + } + + $response->set_data( $is_single ? $threads[0] : $threads ); + + return $response; + } + + /** + * Fetches and prepares the replies belonging to a set of threads. + * + * @since 7.2.0 + * + * @param int[] $thread_ids Top-level note IDs. + * @param WP_REST_Request $request Full details about the request. + * @return array Prepared reply arrays keyed by parent note ID, oldest first. + */ + protected function get_replies( $thread_ids, $request ) { + $query = new WP_Comment_Query(); + + $replies = $query->query( + array( + 'parent__in' => $thread_ids, + 'type' => 'note', + 'status' => 'all', + 'orderby' => 'comment_date_gmt', + 'order' => 'ASC', + 'number' => 0, + 'no_found_rows' => true, + 'update_comment_post_cache' => true, + ) + ); + + $replies_by_parent = array(); + + foreach ( $replies as $reply ) { + if ( ! $this->check_read_permission( $reply, $request ) ) { + continue; + } + + $prepared = $this->prepare_item_for_response( $reply, $request ); + + $replies_by_parent[ (int) $reply->comment_parent ][] = $this->prepare_response_for_collection( $prepared ); + } + + return $replies_by_parent; + } +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 29b7588b96b4f..440e494e5e620 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -337,6 +337,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-notes-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'; diff --git a/tests/phpunit/tests/rest-api/rest-notes-controller.php b/tests/phpunit/tests/rest-api/rest-notes-controller.php new file mode 100644 index 0000000000000..fcfc034c3b893 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-notes-controller.php @@ -0,0 +1,551 @@ +user->create( array( 'role' => 'editor' ) ); + self::$other_editor_id = $factory->user->create( array( 'role' => 'editor' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + + self::$post_id = $factory->post->create( + array( + 'post_author' => self::$editor_id, + 'post_status' => 'publish', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$editor_id ); + self::delete_user( self::$other_editor_id ); + self::delete_user( self::$subscriber_id ); + + wp_delete_post( self::$post_id, true ); + } + + /** + * Creates a note. + * + * @param array $args Optional. Overrides for the comment fields. + * @return int Comment ID. + */ + protected function create_note( $args = array() ) { + return self::factory()->comment->create( + array_merge( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '0', + 'user_id' => self::$editor_id, + 'comment_content' => 'A note.', + ), + $args + ) + ); + } + + /** + * Dispatches a GET request to the notes collection. + * + * @param array $params Optional. Query parameters. + * @return WP_REST_Response Response object. + */ + protected function get_notes( $params = array() ) { + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_query_params( array_merge( array( 'post' => self::$post_id ), $params ) ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * The routes are registered. + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( self::ROUTE, $routes ); + $this->assertArrayHasKey( self::ROUTE . '/(?P[\d]+)', $routes ); + } + + /** + * The collection reads in edit context by default; a single note does not. + * + * @covers ::get_collection_params + */ + public function test_context_param() { + $note = $this->create_note(); + + // Collection. + $request = new WP_REST_Request( 'OPTIONS', self::ROUTE ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( 'edit', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + + // Single. + $request = new WP_REST_Request( 'OPTIONS', self::ROUTE . '/' . $note ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + /** + * The collection returns top-level notes only, with replies nested. + * + * @covers ::get_items + */ + public function test_get_items() { + $thread = $this->create_note(); + $reply_one = $this->create_note( + array( + 'comment_parent' => $thread, + 'comment_content' => 'First reply.', + 'comment_date_gmt' => '2026-01-01 00:00:00', + 'comment_date' => '2026-01-01 00:00:00', + ) + ); + $reply_two = $this->create_note( + array( + 'comment_parent' => $thread, + 'comment_content' => 'Second reply.', + 'comment_date_gmt' => '2026-01-02 00:00:00', + 'comment_date' => '2026-01-02 00:00:00', + ) + ); + + wp_set_current_user( self::$editor_id ); + + $response = $this->get_notes(); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $data, 'Replies should not appear as their own records.' ); + $this->assertSame( $thread, $data[0]['id'] ); + + $reply_ids = wp_list_pluck( $data[0]['replies'], 'id' ); + $this->assertSame( + array( $reply_one, $reply_two ), + $reply_ids, + 'Replies should be nested under the thread, oldest first.' + ); + $this->assertSame( 2, $data[0]['reply_count'] ); + } + + /** + * A single note is returned with its replies. + * + * @covers ::get_item + */ + public function test_get_item() { + $thread = $this->create_note(); + $reply = $this->create_note( array( 'comment_parent' => $thread ) ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', self::ROUTE . '/' . $thread ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $thread, $data['id'] ); + $this->assertSame( array( $reply ), wp_list_pluck( $data['replies'], 'id' ) ); + } + + /** + * Creating a note does not require the client to name the comment type. + * + * @covers ::create_item + */ + public function test_create_item() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( + array( + 'post' => self::$post_id, + 'content' => 'Created through the notes route.', + 'status' => 'hold', + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'note', $data['type'] ); + $this->assertSame( 'hold', $data['status'] ); + $this->assertSame( 'note', get_comment( $data['id'] )->comment_type ); + } + + /** + * Resolving a note is a status update on the note route. + * + * @covers ::update_item + */ + public function test_update_item() { + $note = $this->create_note(); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'PUT', self::ROUTE . '/' . $note ); + $request->set_body_params( array( 'status' => 'approved' ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'approved', $response->get_data()['status'] ); + $this->assertSame( 'note', get_comment( $note )->comment_type ); + } + + /** + * A note can be deleted through its own route. + * + * @covers ::delete_item + */ + public function test_delete_item() { + $note = $this->create_note(); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'DELETE', self::ROUTE . '/' . $note ); + $request->set_param( 'force', true ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['deleted'] ); + $this->assertNull( get_comment( $note ) ); + } + + /** + * A prepared note carries every schema property and nothing else. + * + * @covers ::prepare_item_for_response + */ + public function test_prepare_item() { + $note = $this->create_note(); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', self::ROUTE . '/' . $note ); + $request->set_param( 'context', 'edit' ); + + $data = rest_get_server()->dispatch( $request )->get_data(); + $properties = ( new WP_REST_Notes_Controller() )->get_item_schema()['properties']; + + foreach ( array_keys( $properties ) as $property ) { + $this->assertArrayHasKey( $property, $data, "The `$property` property should be present." ); + } + + $this->assertSame( self::$post_id, $data['post'] ); + $this->assertSame( 'note', $data['type'] ); + } + + /** + * Fields that only make sense for anonymous commenters are not exposed. + * + * @covers ::get_item_schema + */ + public function test_get_item_schema() { + $properties = ( new WP_REST_Notes_Controller() )->get_item_schema()['properties']; + + foreach ( array( 'author_email', 'author_ip', 'author_url', 'author_user_agent', 'link' ) as $removed ) { + $this->assertArrayNotHasKey( $removed, $properties, "The `$removed` property should not be exposed." ); + } + + $this->assertArrayHasKey( 'replies', $properties ); + $this->assertArrayHasKey( 'reply_count', $properties ); + } + + /** + * Replies are prepared in the same context as their thread. + * + * This is the behaviour `_embed` on the comments collection cannot provide: + * embedded children are always prepared in `view` context, so `content.raw`, + * the value the editor writes back, never reaches the client. + * + * @covers ::get_items + */ + public function test_replies_are_prepared_in_edit_context() { + $thread = $this->create_note(); + $this->create_note( + array( + 'comment_parent' => $thread, + 'comment_content' => 'Raw reply body.', + ) + ); + + wp_set_current_user( self::$editor_id ); + + $data = $this->get_notes( array( 'context' => 'edit' ) )->get_data(); + + $this->assertArrayHasKey( 'raw', $data[0]['content'] ); + $this->assertArrayHasKey( 'raw', $data[0]['replies'][0]['content'] ); + $this->assertSame( 'Raw reply body.', $data[0]['replies'][0]['content']['raw'] ); + } + + /** + * Both open and resolved notes come back without asking for a status. + * + * @covers ::get_collection_params + */ + public function test_get_items_defaults_to_all_statuses() { + $open = $this->create_note( array( 'comment_approved' => '0' ) ); + $resolved = $this->create_note( array( 'comment_approved' => '1' ) ); + + wp_set_current_user( self::$editor_id ); + + $data = $this->get_notes()->get_data(); + $statuses = array(); + + foreach ( $data as $note ) { + $statuses[ $note['id'] ] = $note['status']; + } + + $this->assertSame( 'hold', $statuses[ $open ] ); + $this->assertSame( 'approved', $statuses[ $resolved ] ); + } + + /** + * Pagination counts and cuts between threads, never inside one. + * + * @covers ::get_items + */ + public function test_pagination_counts_threads_not_replies() { + $first = $this->create_note( array( 'comment_date_gmt' => '2026-01-01 00:00:00' ) ); + $second = $this->create_note( array( 'comment_date_gmt' => '2026-01-02 00:00:00' ) ); + + $this->create_note( array( 'comment_parent' => $first ) ); + $this->create_note( array( 'comment_parent' => $second ) ); + + wp_set_current_user( self::$editor_id ); + + $response = $this->get_notes( array( 'per_page' => 1 ) ); + $data = $response->get_data(); + $headers = $response->get_headers(); + + $this->assertSame( '2', (string) $headers['X-WP-Total'], 'Only threads should be counted.' ); + $this->assertSame( '2', (string) $headers['X-WP-TotalPages'] ); + $this->assertCount( 1, $data ); + $this->assertCount( 1, $data[0]['replies'], 'A page break must not strip a thread of its replies.' ); + } + + /** + * `_fields` can trim the response down to a per-post tally. + * + * @covers ::attach_replies + */ + public function test_reply_count_is_available_without_the_replies() { + $thread = $this->create_note(); + $this->create_note( array( 'comment_parent' => $thread ) ); + + wp_set_current_user( self::$editor_id ); + + $data = $this->get_notes( array( '_fields' => 'id,post,reply_count' ) )->get_data(); + + $this->assertSame( + array( 'id', 'post', 'reply_count' ), + array_keys( $data[0] ) + ); + $this->assertSame( 1, $data[0]['reply_count'] ); + } + + /** + * The collection is scoped to a post. + * + * @covers ::get_items_permissions_check + */ + public function test_get_items_requires_a_post() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 400, $response->get_status() ); + } + + /** + * Anyone who can edit the post can read its notes, not just their author. + * + * @covers ::get_items_permissions_check + */ + public function test_notes_are_readable_by_every_editor_of_the_post() { + $this->create_note( array( 'user_id' => self::$editor_id ) ); + + wp_set_current_user( self::$other_editor_id ); + + $response = $this->get_notes(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $response->get_data() ); + } + + /** + * Users who cannot edit the post cannot read its notes. + * + * @covers ::get_items_permissions_check + */ + public function test_get_items_denied_without_edit_post() { + $this->create_note(); + + wp_set_current_user( self::$subscriber_id ); + + $this->assertErrorResponse( 'rest_cannot_read_notes', $this->get_notes(), 403 ); + } + + /** + * Logged-out requests are rejected outright. + * + * @covers ::get_items_permissions_check + */ + public function test_get_items_denied_when_logged_out() { + $this->create_note(); + + wp_set_current_user( 0 ); + + $this->assertErrorResponse( 'rest_notes_not_logged_in', $this->get_notes(), 401 ); + } + + /** + * Post types that do not opt into notes have no notes to read. + * + * @covers ::get_items_permissions_check + */ + public function test_get_items_denied_for_post_type_without_notes_support() { + register_post_type( 'no_notes', array( 'supports' => array( 'editor' ) ) ); + + $unsupported_id = self::factory()->post->create( + array( + 'post_type' => 'no_notes', + 'post_author' => self::$editor_id, + ) + ); + + wp_set_current_user( self::$editor_id ); + + $response = $this->get_notes( array( 'post' => $unsupported_id ) ); + + unregister_post_type( 'no_notes' ); + + $this->assertErrorResponse( 'rest_note_not_supported_post_type', $response, 403 ); + } + + /** + * The single-note routes do not expose ordinary comments. + * + * @covers ::get_comment + */ + public function test_single_route_rejects_a_regular_comment() { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_approved' => '1', + ) + ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', self::ROUTE . '/' . $comment_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_note_invalid_id', $response, 404 ); + } + + /** + * A reply created through the route shows up nested in the thread. + * + * @covers ::create_item + */ + public function test_created_reply_is_nested_in_its_thread() { + $thread = $this->create_note(); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( + array( + 'post' => self::$post_id, + 'parent' => $thread, + 'content' => 'A reply.', + 'status' => 'hold', + ) + ); + + $created = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $created->get_status() ); + + $data = $this->get_notes()->get_data(); + + $this->assertCount( 1, $data, 'The reply should not surface as its own thread.' ); + $this->assertSame( + array( $created->get_data()['id'] ), + wp_list_pluck( $data[0]['replies'], 'id' ) + ); + } + + /** + * The thread list does not build a `children` link per note. + * + * @covers ::prepare_links + */ + public function test_links_omit_children() { + $thread = $this->create_note(); + $this->create_note( array( 'comment_parent' => $thread ) ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'GET', self::ROUTE . '/' . $thread ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertArrayNotHasKey( 'children', $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 9ee6cc4cbed16..690524bed447c 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/notes', + '/wp/v2/notes/(?P[\\d]+)', '/wp/v2/global-styles/(?P[\/\d+]+)', '/wp/v2/global-styles/(?P[\d]+)/revisions', '/wp/v2/global-styles/(?P[\d]+)/revisions/(?P[\d]+)', From 29711199aacf7819911e23429fcc2b07c58afc99 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 13 Aug 2026 16:07:19 -0700 Subject: [PATCH 2/5] REST API: Regenerate the QUnit API fixture for the notes routes. --- tests/qunit/fixtures/wp-api-generated.js | 364 +++++++++++++++++++++++ 1 file changed, 364 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 4a2d5a3ac7ea8..7d4b0a2e08faf 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -10877,6 +10877,370 @@ mockedApiResponse.Schema = { } ] }, + "/wp/v2/notes": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "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": "edit", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "after": { + "description": "Limit response to comments published after a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "author": { + "description": "Limit result set to comments assigned to specific user IDs. Requires authorization.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + }, + "author_exclude": { + "description": "Ensure result set excludes comments assigned to specific user IDs. Requires authorization.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + }, + "before": { + "description": "Limit response to comments published before a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by comment attribute.", + "type": "string", + "default": "date_gmt", + "enum": [ + "date", + "date_gmt", + "id", + "include", + "post", + "parent", + "type" + ], + "required": false + }, + "post": { + "description": "Limit result set to comments assigned to specific post IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "required": true + }, + "status": { + "default": "all", + "description": "Limit result set to comments assigned a specific status. Requires authorization.", + "type": "string", + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "args": { + "author": { + "description": "The ID of the user object, if author was a user.", + "type": "integer", + "required": false + }, + "author_name": { + "description": "Display name for the comment author.", + "type": "string", + "required": false + }, + "content": { + "description": "The content for the comment.", + "type": "object", + "properties": { + "raw": { + "description": "Content for the comment, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML content for the comment, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "date": { + "description": "The date the comment was published, in the site's timezone.", + "type": "string", + "format": "date-time", + "required": false + }, + "date_gmt": { + "description": "The date the comment was published, as GMT.", + "type": "string", + "format": "date-time", + "required": false + }, + "parent": { + "default": 0, + "description": "The ID for the parent of the comment.", + "type": "integer", + "required": false + }, + "post": { + "default": 0, + "description": "The ID of the associated post object.", + "type": "integer", + "required": false + }, + "status": { + "description": "State of the comment.", + "type": "string", + "required": false + }, + "meta": { + "description": "Meta fields.", + "type": "object", + "properties": [], + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/notes" + } + ] + } + }, + "/wp/v2/notes/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "id": { + "description": "Unique identifier for the comment.", + "type": "integer", + "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 + }, + "password": { + "description": "The password for the parent post of the comment (if the post is password protected).", + "type": "string", + "required": false + } + } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "args": { + "id": { + "description": "Unique identifier for the comment.", + "type": "integer", + "required": false + }, + "author": { + "description": "The ID of the user object, if author was a user.", + "type": "integer", + "required": false + }, + "author_name": { + "description": "Display name for the comment author.", + "type": "string", + "required": false + }, + "content": { + "description": "The content for the comment.", + "type": "object", + "properties": { + "raw": { + "description": "Content for the comment, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML content for the comment, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "date": { + "description": "The date the comment was published, in the site's timezone.", + "type": "string", + "format": "date-time", + "required": false + }, + "date_gmt": { + "description": "The date the comment was published, as GMT.", + "type": "string", + "format": "date-time", + "required": false + }, + "parent": { + "description": "The ID for the parent of the comment.", + "type": "integer", + "required": false + }, + "post": { + "description": "The ID of the associated post object.", + "type": "integer", + "required": false + }, + "status": { + "description": "State of the comment.", + "type": "string", + "required": false + }, + "meta": { + "description": "Meta fields.", + "type": "object", + "properties": [], + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": { + "id": { + "description": "Unique identifier for the comment.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Whether to bypass Trash and force deletion.", + "required": false + }, + "password": { + "description": "The password for the parent post of the comment (if the post is password protected).", + "type": "string", + "required": false + } + } + } + ] + }, "/wp/v2/search": { "namespace": "wp/v2", "methods": [ From 40f2a65e94d12da193991431d937a3ab000636d7 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 13 Aug 2026 16:07:19 -0700 Subject: [PATCH 3/5] REST API: Move note handling out of the comments controller. With wp/v2/notes serving notes, the comments controller no longer needs to branch on comment type. Note permissions, the empty-note-on-resolve allowance, duplicate and flood bypass, and the post type support check all move to WP_REST_Notes_Controller, reached through three protected seams: get_allowed_comment_types(), prepare_comment_for_content_check() and determine_comment_approval(). One branch stays: check_read_permission() keeps excluding notes from the approved-comment shortcut. Notes are stored approved, so without it the comments routes would hand them to anonymous readers by ID. --- .../class-wp-rest-comments-controller.php | 183 +++----- .../class-wp-rest-notes-controller.php | 172 ++++++- .../rest-api/rest-comments-controller.php | 440 ------------------ .../tests/rest-api/rest-notes-controller.php | 302 ++++++++++++ 4 files changed, 526 insertions(+), 571 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index f2c7634a8906c..8e1ef47902f66 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -123,11 +123,6 @@ public function register_routes() { * @return true|WP_Error True if the request has read access, error object otherwise. */ public function get_items_permissions_check( $request ) { - $is_note = 'note' === $request['type']; - $is_edit_context = 'edit' === $request['context']; - $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' ); - $forbidden_params = array(); - if ( ! empty( $request['post'] ) ) { foreach ( (array) $request['post'] as $post_id ) { $post = get_post( $post_id ); @@ -145,51 +140,10 @@ public function get_items_permissions_check( $request ) { array( 'status' => rest_authorization_required_code() ) ); } - - if ( $post && $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) { - if ( current_user_can( 'edit_post', $post->ID ) ) { - return new WP_Error( - 'rest_comment_not_supported_post_type', - __( 'Sorry, this post type does not support notes.' ), - array( 'status' => 403 ) - ); - } - - foreach ( $protected_params as $param ) { - if ( 'status' === $param ) { - if ( 'approve' !== $request[ $param ] ) { - $forbidden_params[] = $param; - } - } elseif ( 'type' === $param ) { - if ( 'comment' !== $request[ $param ] ) { - $forbidden_params[] = $param; - } - } elseif ( ! empty( $request[ $param ] ) ) { - $forbidden_params[] = $param; - } - } - return new WP_Error( - 'rest_forbidden_param', - /* translators: %s: List of forbidden parameters. */ - sprintf( __( 'Query parameter not permitted: %s' ), implode( ', ', $forbidden_params ) ), - array( 'status' => rest_authorization_required_code() ) - ); - } } } - // Re-map edit context capabilities when requesting `note` for a post. - if ( $is_edit_context && $is_note && ! empty( $request['post'] ) ) { - foreach ( (array) $request['post'] as $post_id ) { - if ( ! current_user_can( 'edit_post', $post_id ) ) { - return new WP_Error( - 'rest_forbidden_context', - __( 'Sorry, you are not allowed to edit comments.' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - } - } elseif ( $is_edit_context && ! current_user_can( 'moderate_comments' ) ) { + if ( 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit comments.' ), @@ -198,6 +152,9 @@ public function get_items_permissions_check( $request ) { } if ( ! current_user_can( 'edit_posts' ) ) { + $protected_params = array( 'author', 'author_exclude', 'author_email', 'type', 'status' ); + $forbidden_params = array(); + foreach ( $protected_params as $param ) { if ( 'status' === $param ) { if ( 'approve' !== $request[ $param ] ) { @@ -437,9 +394,7 @@ public function get_item_permissions_check( $request ) { return $comment; } - // Re-map edit context capabilities when requesting `note` type. - $edit_cap = 'note' === $comment->comment_type ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); - if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { + if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit comments.' ), @@ -497,16 +452,6 @@ public function get_item( $request ) { * @return true|WP_Error True if the request has access to create items, error object otherwise. */ public function create_item_permissions_check( $request ) { - $is_note = ! empty( $request['type'] ) && 'note' === $request['type']; - - if ( ! is_user_logged_in() && $is_note ) { - return new WP_Error( - 'rest_comment_login_required', - __( 'Sorry, you must be logged in to comment.' ), - array( 'status' => 401 ) - ); - } - if ( ! is_user_logged_in() ) { if ( get_option( 'comment_registration' ) ) { return new WP_Error( @@ -560,16 +505,7 @@ public function create_item_permissions_check( $request ) { } } - if ( $is_note && ! empty( $request['post'] ) && ! current_user_can( 'edit_post', (int) $request['post'] ) ) { - return new WP_Error( - 'rest_cannot_create_note', - __( 'Sorry, you are not allowed to create notes for this post.' ), - array( 'status' => rest_authorization_required_code() ) - ); - } - - $edit_cap = $is_note ? array( 'edit_post', (int) $request['post'] ) : array( 'moderate_comments' ); - if ( isset( $request['status'] ) && ! current_user_can( ...$edit_cap ) ) { + if ( isset( $request['status'] ) && ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( 'rest_comment_invalid_status', /* translators: %s: Request parameter. */ @@ -596,15 +532,7 @@ public function create_item_permissions_check( $request ) { ); } - if ( $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) { - return new WP_Error( - 'rest_comment_not_supported_post_type', - __( 'Sorry, this post type does not support notes.' ), - array( 'status' => 403 ) - ); - } - - if ( 'draft' === $post->post_status && ! $is_note ) { + if ( 'draft' === $post->post_status ) { return new WP_Error( 'rest_comment_draft_post', __( 'Sorry, you are not allowed to create a comment on this post.' ), @@ -628,7 +556,7 @@ public function create_item_permissions_check( $request ) { ); } - if ( ! comments_open( $post->ID ) && ! $is_note ) { + if ( ! comments_open( $post->ID ) ) { return new WP_Error( 'rest_comment_closed', __( 'Sorry, comments are closed for this item.' ), @@ -657,7 +585,7 @@ public function create_item( $request ) { } // Do not allow comments to be created with a non-core type. - if ( ! empty( $request['type'] ) && ! in_array( $request['type'], array( 'comment', 'note' ), true ) ) { + if ( ! empty( $request['type'] ) && ! in_array( $request['type'], $this->get_allowed_comment_types(), true ) ) { return new WP_Error( 'rest_invalid_comment_type', __( 'Cannot create a comment with that type.' ), @@ -676,10 +604,7 @@ public function create_item( $request ) { $prepared_comment['comment_content'] = ''; } - // Include note metadata into check_is_comment_content_allowed. - if ( isset( $request['meta']['_wp_note_status'] ) ) { - $prepared_comment['meta']['_wp_note_status'] = $request['meta']['_wp_note_status']; - } + $prepared_comment = $this->prepare_comment_for_content_check( $prepared_comment, $request ); if ( ! $this->check_is_comment_content_allowed( $prepared_comment ) ) { return new WP_Error( @@ -743,11 +668,7 @@ public function create_item( $request ) { ); } - // Don't check for duplicates or flooding for notes. - $prepared_comment['comment_approved'] = - 'note' === $prepared_comment['comment_type'] ? - '1' : - wp_allow_comment( $prepared_comment, true ); + $prepared_comment['comment_approved'] = $this->determine_comment_approval( $prepared_comment ); if ( is_wp_error( $prepared_comment['comment_approved'] ) ) { $error_code = $prepared_comment['comment_approved']->get_error_code(); @@ -1304,22 +1225,6 @@ protected function prepare_links( $comment ) { ); } - // Embedding children for notes requires `type` and `status` inheritance. - if ( isset( $links['children'] ) && 'note' === $comment->comment_type ) { - $args = array( - 'parent' => $comment->comment_ID, - 'type' => $comment->comment_type, - 'status' => 'all', - ); - - $rest_url = add_query_arg( $args, rest_url( $this->namespace . '/' . $this->rest_base ) ); - - $links['children'] = array( - 'href' => $rest_url, - 'embeddable' => true, - ); - } - return $links; } @@ -1919,6 +1824,13 @@ protected function check_read_post_permission( $post, $request ) { * @return bool Whether the comment can be read. */ protected function check_read_permission( $comment, $request ) { + /* + * Notes stay out of the "approved comment on a readable post" shortcut. + * Every note is stored approved - `approved` means resolved, not public - + * so the shortcut would hand notes to anonymous readers. This is the one + * piece of note handling the comments controller has to keep: a note is a + * comment row, and the comments routes can still address it by ID. + */ if ( 'note' !== $comment->comment_type && ! empty( $comment->comment_post_ID ) ) { $post = get_post( $comment->comment_post_ID ); if ( $post ) { @@ -2024,16 +1936,6 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { return true; } - // Allow empty notes only when resolution metadata is valid. - if ( - isset( $check['comment_type'] ) && - 'note' === $check['comment_type'] && - isset( $check['meta']['_wp_note_status'] ) && - in_array( $check['meta']['_wp_note_status'], array( 'resolved', 'reopen' ), true ) - ) { - return true; - } - /* * Do not allow a comment to be created with missing or empty * comment_content. See wp_handle_comment_submission(). @@ -2042,22 +1944,45 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { } /** - * Check if post type supports notes. + * Retrieves the comment types this controller may create. + * + * Subclasses serving a different comment type, such as + * WP_REST_Notes_Controller, name that type here. + * + * @since 7.2.0 * - * @param string $post_type Post type name. - * @return bool True if post type supports notes, false otherwise. + * @return string[] Comment types accepted by the create route. */ - protected function check_post_type_supports_notes( $post_type ) { - $supports = get_all_post_type_supports( $post_type ); - - if ( ! isset( $supports['editor'] ) ) { - return false; - } + protected function get_allowed_comment_types() { + return array( 'comment' ); + } - if ( ! is_array( $supports['editor'] ) ) { - return false; - } + /** + * Adds any extra request values the content check needs. + * + * Prepared comments carry only the columns headed for the comments table. + * A subclass whose content rules depend on something else, such as the meta + * a note is resolved with, copies that value across here. + * + * @since 7.2.0 + * + * @param array $prepared_comment Prepared comment data. + * @param WP_REST_Request $request Full details about the request. + * @return array Prepared comment data for the content check. + */ + protected function prepare_comment_for_content_check( $prepared_comment, $request ) { + return $prepared_comment; + } - return array_any( $supports['editor'], fn( $item ) => ! empty( $item['notes'] ) ); + /** + * Determines the approval status a new comment is created with. + * + * @since 7.2.0 + * + * @param array $prepared_comment Prepared comment data. + * @return int|string|WP_Error The approval status, or WP_Error on failure. + */ + protected function determine_comment_approval( $prepared_comment ) { + return wp_allow_comment( $prepared_comment, true ); } } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php index 157121d133b77..630984edc8ac3 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php @@ -144,6 +144,37 @@ public function get_item( $request ) { return $this->attach_replies( $response, $request ); } + /** + * Checks if a given request has access to read a note. + * + * Reading a note in any context is the same permission as editing it: the + * comments controller's `moderate_comments` gate does not apply, because a + * note belongs to whoever is editing the post rather than to the site's + * comment moderators. + * + * @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. + */ + public function get_item_permissions_check( $request ) { + $note = $this->get_comment( $request['id'] ); + + if ( is_wp_error( $note ) ) { + return $note; + } + + if ( ! current_user_can( 'edit_comment', $note->comment_ID ) ) { + return new WP_Error( + 'rest_cannot_read_notes', + __( 'Sorry, you are not allowed to read notes for this post.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + /** * Creates a note. * @@ -161,15 +192,68 @@ public function create_item( $request ) { /** * Checks if a given request has access to create a note. * + * A note is an editorial act on a post, so none of the public commenting + * rules apply: there is no anonymous path, no `comments_open` check, and a + * draft is exactly the kind of post that gets annotated. + * * @since 7.2.0 * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has access to create items, WP_Error object otherwise. */ public function create_item_permissions_check( $request ) { - $request['type'] = 'note'; + if ( ! is_user_logged_in() ) { + return new WP_Error( + 'rest_notes_not_logged_in', + __( 'Sorry, you are not allowed to create notes.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + if ( isset( $request['author'] ) && get_current_user_id() !== (int) $request['author'] ) { + return new WP_Error( + 'rest_note_invalid_author', + /* translators: %s: Request parameter. */ + sprintf( __( "Sorry, you are not allowed to edit '%s' for notes." ), 'author' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $post = get_post( (int) $request['post'] ); + + if ( ! $post ) { + return new WP_Error( + 'rest_note_invalid_post_id', + __( 'Sorry, you are not allowed to create a note without a post.' ), + array( 'status' => 403 ) + ); + } + + if ( ! $this->check_post_type_supports_notes( $post->post_type ) ) { + return new WP_Error( + 'rest_note_not_supported_post_type', + __( 'Sorry, this post type does not support notes.' ), + array( 'status' => 403 ) + ); + } + + if ( 'trash' === $post->post_status ) { + return new WP_Error( + 'rest_note_trash_post', + __( 'Sorry, you are not allowed to create a note on this post.' ), + array( 'status' => 403 ) + ); + } + + if ( ! current_user_can( 'edit_post', $post->ID ) ) { + return new WP_Error( + 'rest_cannot_create_note', + __( 'Sorry, you are not allowed to create notes for this post.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } - return parent::create_item_permissions_check( $request ); + return true; } /** @@ -348,6 +432,90 @@ protected function check_note_post_permission( $post_id ) { return true; } + /** + * Determines whether a post type opts into notes. + * + * @since 7.2.0 + * + * @param string $post_type Post type name. + * @return bool True when the post type's editor support declares notes. + */ + protected function check_post_type_supports_notes( $post_type ) { + $supports = get_all_post_type_supports( $post_type ); + + if ( ! isset( $supports['editor'] ) || ! is_array( $supports['editor'] ) ) { + return false; + } + + return array_any( $supports['editor'], fn( $item ) => ! empty( $item['notes'] ) ); + } + + /** + * Restricts the create route to notes. + * + * @since 7.2.0 + * + * @return string[] Comment types accepted by the create route. + */ + protected function get_allowed_comment_types() { + return array( 'note' ); + } + + /** + * Carries the resolution status into the content check. + * + * Resolving a note posts no text of its own, so the check needs the meta to + * tell an intentional empty note from an empty one. + * + * @since 7.2.0 + * + * @param array $prepared_comment Prepared comment data. + * @param WP_REST_Request $request Full details about the request. + * @return array Prepared comment data for the content check. + */ + protected function prepare_comment_for_content_check( $prepared_comment, $request ) { + if ( isset( $request['meta']['_wp_note_status'] ) ) { + $prepared_comment['meta']['_wp_note_status'] = $request['meta']['_wp_note_status']; + } + + return $prepared_comment; + } + + /** + * Approves every note on creation. + * + * Notes are written by users who can already edit the post, so there is + * nothing for duplicate and flood control to protect against. `status` on a + * note means open or resolved, and is set separately. + * + * @since 7.2.0 + * + * @param array $prepared_comment Prepared comment data. + * @return string The approval status. + */ + protected function determine_comment_approval( $prepared_comment ) { + return '1'; + } + + /** + * Allows a note with no text when it records a resolution. + * + * @since 7.2.0 + * + * @param array $prepared_comment Prepared comment data. + * @return bool True if the content is allowed, false otherwise. + */ + protected function check_is_comment_content_allowed( $prepared_comment ) { + if ( + isset( $prepared_comment['meta']['_wp_note_status'] ) && + in_array( $prepared_comment['meta']['_wp_note_status'], array( 'resolved', 'reopen' ), true ) + ) { + return true; + } + + return parent::check_is_comment_content_allowed( $prepared_comment ); + } + /** * Nests each thread's replies into the prepared response. * diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 7162b278839d5..12763e7f44d31 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -28,7 +28,6 @@ class WP_Test_REST_Comments_Controller extends WP_Test_REST_Controller_Testcase protected static $comment_ids = array(); protected static $total_comments = 30; protected static $per_page = 50; - protected static $num_notes = 10; protected $endpoint; @@ -3643,176 +3642,6 @@ public static function data_head_request_with_specified_fields_returns_success_r ); } - /** - * Create a test post with note. - * - * @param string $role User role to assign the post author. - * @return int Post ID. - */ - protected function create_test_post_with_note( $role ) { - $user_id = self::$user_ids[ $role ]; - $post_id = self::factory()->post->create( - array( - 'post_title' => 'Test Post for Notes', - 'post_content' => 'This is a test post to check note permissions.', - 'post_status' => 'contributor' === $role ? 'draft' : 'publish', - 'post_author' => $user_id, - ) - ); - - for ( $i = 0; $i < self::$num_notes; $i++ ) { - self::factory()->comment->create( - array( - 'comment_post_ID' => $post_id, - 'comment_type' => 'note', - 'comment_approved' => 0 === $i % 2 ? 1 : 0, - ) - ); - } - - return $post_id; - } - - /** - * @ticket 64096 - */ - public function test_cannot_read_note_without_post_type_support() { - register_post_type( - 'no-notes', - array( - 'label' => 'No Notes', - 'supports' => array( 'title', 'editor', 'author', 'comments' ), - 'show_in_rest' => true, - 'public' => true, - ) - ); - - create_initial_rest_routes(); - wp_set_current_user( self::$admin_id ); - - $post_id = self::factory()->post->create( array( 'post_type' => 'no-notes' ) ); - $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); - $request->set_param( 'post', $post_id ); - $request->set_param( 'type', 'note' ); - $request->set_param( 'context', 'edit' ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_comment_not_supported_post_type', $response, 403 ); - - _unregister_post_type( 'no-notes' ); - } - - /** - * @ticket 64096 - */ - public function test_create_note_require_login() { - wp_set_current_user( 0 ); - - $post_id = self::factory()->post->create(); - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->set_param( 'post', $post_id ); - $request->set_param( 'type', 'note' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_comment_login_required', $response, 401 ); - } - - /** - * @ticket 64096 - */ - public function test_cannot_create_note_without_post_type_support() { - register_post_type( - 'no-note', - array( - 'label' => 'No Notes', - 'supports' => array( 'title', 'editor', 'author', 'comments' ), - 'show_in_rest' => true, - 'public' => true, - ) - ); - - wp_set_current_user( self::$admin_id ); - $post_id = self::factory()->post->create( array( 'post_type' => 'no-note' ) ); - $params = array( - 'post' => $post_id, - 'author_name' => 'Ishmael', - 'author_email' => 'herman-melville@earthlink.net', - 'author_url' => 'https://en.wikipedia.org/wiki/Herman_Melville', - 'content' => 'Call me Ishmael.', - 'author' => self::$admin_id, - 'type' => 'note', - ); - - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( $params ) ); - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_comment_not_supported_post_type', $response, 403 ); - - _unregister_post_type( 'no-note' ); - } - - /** - * @ticket 64096 - */ - public function test_create_note_draft_post() { - wp_set_current_user( self::$editor_id ); - $draft_id = self::factory()->post->create( - array( - 'post_status' => 'draft', - ) - ); - $params = array( - 'post' => $draft_id, - 'author_name' => 'Ishmael', - 'author_email' => 'herman-melville@earthlink.net', - 'author_url' => 'https://en.wikipedia.org/wiki/Herman_Melville', - 'content' => 'Call me Ishmael.', - 'author' => self::$editor_id, - 'type' => 'note', - ); - - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( $params ) ); - - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - $new_comment = get_comment( $data['id'] ); - $this->assertSame( 'Call me Ishmael.', $new_comment->comment_content ); - $this->assertSame( 'note', $new_comment->comment_type ); - } - - /** - * @ticket 64096 - */ - public function test_create_note_status() { - wp_set_current_user( self::$author_id ); - $post_id = self::factory()->post->create( array( 'post_author' => self::$author_id ) ); - - $params = array( - 'post' => $post_id, - 'author_name' => 'Ishmael', - 'author_email' => 'herman-melville@earthlink.net', - 'author_url' => 'https://en.wikipedia.org/wiki/Herman_Melville', - 'content' => 'Comic Book Guy', - 'author' => self::$author_id, - 'type' => 'note', - 'status' => 'hold', - ); - - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( $params ) ); - - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - $new_comment = get_comment( $data['id'] ); - - $this->assertSame( '0', $new_comment->comment_approved ); - $this->assertSame( 'note', $new_comment->comment_type ); - } - /** * @ticket 64096 */ @@ -3865,275 +3694,6 @@ public function test_create_assigns_default_type() { $this->assertSame( 'comment', $new_comment->comment_type ); } - /** - * @dataProvider data_note_status_provider - * @ticket 64096 - */ - public function test_create_empty_note_with_resolution_meta( $status ) { - wp_set_current_user( self::$editor_id ); - $post_id = self::factory()->post->create(); - $params = array( - 'post' => $post_id, - 'author_name' => 'Editor', - 'author_email' => 'editor@example.com', - 'author_url' => 'https://example.com', - 'author' => self::$editor_id, - 'type' => 'note', - 'content' => '', - 'meta' => array( - '_wp_note_status' => $status, - ), - ); - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( $params ) ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 201, $response->get_status() ); - - $data = $response->get_data(); - $this->assertArrayHasKey( 'meta', $data ); - $this->assertArrayHasKey( '_wp_note_status', $data['meta'] ); - $this->assertSame( $status, $data['meta']['_wp_note_status'] ); - } - - /** - * @ticket 64096 - */ - public function test_cannot_create_empty_note_without_resolution_meta() { - wp_set_current_user( self::$editor_id ); - $post_id = self::factory()->post->create(); - $params = array( - 'post' => $post_id, - 'author_name' => 'Editor', - 'author_email' => 'editor@example.com', - 'author_url' => 'https://example.com', - 'author' => self::$editor_id, - 'type' => 'note', - 'content' => '', - ); - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( $params ) ); - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_comment_content_invalid', $response, 400 ); - } - - /** - * @ticket 64096 - */ - public function test_cannot_create_empty_note_with_invalid_resolution_meta() { - wp_set_current_user( self::$editor_id ); - $post_id = self::factory()->post->create(); - $params = array( - 'post' => $post_id, - 'author_name' => 'Editor', - 'author_email' => 'editor@example.com', - 'author_url' => 'https://example.com', - 'author' => self::$editor_id, - 'type' => 'note', - 'content' => '', - 'meta' => array( - '_wp_note_status' => 'invalid', - ), - ); - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( $params ) ); - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_comment_content_invalid', $response, 400 ); - } - - /** - * @ticket 64096 - */ - public function test_create_duplicate_note() { - wp_set_current_user( self::$editor_id ); - $post_id = self::factory()->post->create(); - - for ( $i = 0; $i < 2; $i++ ) { - $params = array( - 'post' => $post_id, - 'author_name' => 'Editor', - 'author_email' => 'editor@example.com', - 'author_url' => 'https://example.com', - 'author' => self::$editor_id, - 'type' => 'note', - 'content' => 'Doplicated comment', - ); - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); - $request->add_header( 'Content-Type', 'application/json' ); - $request->set_body( wp_json_encode( $params ) ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 201, $response->get_status() ); - } - } - - /** - * @dataProvider data_note_get_items_permissions_data_provider - * @ticket 64096 - */ - public function test_note_get_items_permissions_edit_context( $role, $post_author_role, $can_read ) { - wp_set_current_user( self::$user_ids[ $role ] ); - $post_id = $this->create_test_post_with_note( $post_author_role ); - - $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); - $request->set_param( 'post', $post_id ); - $request->set_param( 'type', 'note' ); - $request->set_param( 'status', 'all' ); - $request->set_param( 'per_page', 100 ); - $request->set_param( 'context', 'edit' ); - $response = rest_get_server()->dispatch( $request ); - - if ( $can_read ) { - $comments = $response->get_data(); - $this->assertEquals( self::$num_notes, count( $comments ) ); - } else { - $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); - } - - wp_delete_post( $post_id, true ); - } - - /** - * @ticket 64096 - */ - public function test_note_get_items_permissions_mixed_post_authors() { - $author_post_id = $this->create_test_post_with_note( 'author' ); - $editor_post_id = $this->create_test_post_with_note( 'editor' ); - - wp_set_current_user( self::$author_id ); - - $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); - $request->set_param( 'post', array( $author_post_id, $editor_post_id ) ); - $request->set_param( 'type', 'note' ); - $request->set_param( 'status', 'all' ); - $request->set_param( 'per_page', 100 ); - $request->set_param( 'context', 'edit' ); - $response = rest_get_server()->dispatch( $request ); - - $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); - - wp_delete_post( $author_post_id, true ); - wp_delete_post( $editor_post_id, true ); - } - - /** - * @dataProvider data_note_get_items_permissions_data_provider - * @ticket 64096 - */ - public function test_note_get_item_permissions_edit_context( $role, $post_author_role, $can_read ) { - wp_set_current_user( self::$user_ids[ $role ] ); - - $post_id = self::factory()->post->create( - array( - 'post_title' => 'Test Post for Block Comments', - 'post_content' => 'This is a test post to check block comment permissions.', - 'post_status' => 'contributor' === $post_author_role ? 'draft' : 'publish', - 'post_author' => self::$user_ids[ $post_author_role ], - ) - ); - - $comment_id = self::factory()->comment->create( - array( - 'comment_post_ID' => $post_id, - 'comment_type' => 'note', - // Test with unapproved comment, which is more restrictive. - 'comment_approved' => 0, - 'user_id' => self::$user_ids[ $post_author_role ], - ) - ); - - $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $comment_id ); - $request->set_param( 'context', 'edit' ); - $response = rest_get_server()->dispatch( $request ); - - if ( $can_read ) { - $comment = $response->get_data(); - $this->assertEquals( $comment_id, $comment['id'] ); - } else { - $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); - } - - wp_delete_post( $post_id, true ); - } - - public function data_note_get_items_permissions_data_provider() { - return array( - 'Administrator can see note on other posts' => array( 'administrator', 'author', true ), - 'Editor can see note on other posts' => array( 'editor', 'contributor', true ), - 'Author cannot see note on other posts' => array( 'author', 'editor', false ), - 'Contributor cannot see note on other posts' => array( 'contributor', 'author', false ), - 'Subscriber cannot see note' => array( 'subscriber', 'author', false ), - 'Author can see note on own post' => array( 'author', 'author', true ), - 'Contributor can see note on own post' => array( 'contributor', 'contributor', true ), - ); - } - - public function data_note_status_provider() { - return array( - 'resolved' => array( 'resolved' ), - 'reopen' => array( 'reopen' ), - ); - } - - /** - * Test children link for note comment type. Based on test_get_comment_with_children_link. - * - * @ticket 64152 - */ - public function test_get_note_with_children_link() { - $parent_comment_id = self::factory()->comment->create( - array( - 'comment_approved' => 1, - 'comment_post_ID' => self::$post_id, - 'user_id' => self::$admin_id, - 'comment_type' => 'note', - 'comment_content' => 'Parent note comment', - ) - ); - - self::factory()->comment->create( - array( - 'comment_approved' => 1, - 'comment_parent' => $parent_comment_id, - 'comment_post_ID' => self::$post_id, - 'user_id' => self::$admin_id, - 'comment_type' => 'note', - 'comment_content' => 'First child note comment', - ) - ); - - wp_set_current_user( self::$admin_id ); - $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/comments/%s', $parent_comment_id ) ); - $request->set_param( 'type', 'note' ); - $request->set_param( 'context', 'edit' ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - - $this->assertArrayHasKey( 'children', $response->get_links() ); - - $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); - $request->set_param( 'post', self::$post_id ); - $request->set_param( 'type', 'note' ); - $request->set_param( 'context', 'edit' ); - $request->set_param( 'parent', 0 ); - - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - - $this->assertArrayHasKey( '_links', $data[0] ); - $this->assertArrayHasKey( 'children', $data[0]['_links'] ); - - $children = $data[0]['_links']['children']; - - // Verify the href attribute contains the expected status and type parameters. - $this->assertStringContainsString( 'status=all', $children[0]['href'] ); - $this->assertStringContainsString( 'type=note', $children[0]['href'] ); - } - /** * Test retrieving comments by type as authenticated user. * diff --git a/tests/phpunit/tests/rest-api/rest-notes-controller.php b/tests/phpunit/tests/rest-api/rest-notes-controller.php index fcfc034c3b893..f787e9218e248 100644 --- a/tests/phpunit/tests/rest-api/rest-notes-controller.php +++ b/tests/phpunit/tests/rest-api/rest-notes-controller.php @@ -59,6 +59,16 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { ); } + public function set_up() { + parent::set_up(); + + /* + * The test case unregisters every meta key between tests, and + * `_wp_note_status` is registered on `init`, which has already fired. + */ + wp_create_initial_comment_meta(); + } + public static function wpTearDownAfterClass() { self::delete_user( self::$editor_id ); self::delete_user( self::$other_editor_id ); @@ -532,6 +542,298 @@ public function test_created_reply_is_nested_in_its_thread() { ); } + /** + * A draft is exactly the kind of post that gets annotated. + * + * @covers ::create_item_permissions_check + */ + public function test_create_item_on_a_draft_post() { + $draft_id = self::factory()->post->create( + array( + 'post_status' => 'draft', + 'post_author' => self::$editor_id, + ) + ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( + array( + 'post' => $draft_id, + 'content' => 'A note on a draft.', + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'note', get_comment( $response->get_data()['id'] )->comment_type ); + } + + /** + * A closed discussion does not close the editorial one. + * + * @covers ::create_item_permissions_check + */ + public function test_create_item_when_comments_are_closed() { + $closed_id = self::factory()->post->create( + array( + 'post_author' => self::$editor_id, + 'comment_status' => 'closed', + ) + ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( + array( + 'post' => $closed_id, + 'content' => 'Comments are closed, notes are not.', + ) + ); + + $this->assertSame( 201, rest_get_server()->dispatch( $request )->get_status() ); + } + + /** + * Notes cannot be created anonymously, whatever the discussion settings say. + * + * @covers ::create_item_permissions_check + */ + public function test_create_item_requires_login() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( + array( + 'post' => self::$post_id, + 'content' => 'Anonymous note.', + ) + ); + + $this->assertErrorResponse( 'rest_notes_not_logged_in', rest_get_server()->dispatch( $request ), 401 ); + } + + /** + * Users who cannot edit the post cannot annotate it. + * + * @covers ::create_item_permissions_check + */ + public function test_create_item_denied_without_edit_post() { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( + array( + 'post' => self::$post_id, + 'content' => 'Not mine to annotate.', + ) + ); + + $this->assertErrorResponse( 'rest_cannot_create_note', rest_get_server()->dispatch( $request ), 403 ); + } + + /** + * Post types that do not opt into notes cannot be annotated either. + * + * @covers ::create_item_permissions_check + */ + public function test_create_item_denied_for_post_type_without_notes_support() { + register_post_type( 'no_notes', array( 'supports' => array( 'editor' ) ) ); + + $unsupported_id = self::factory()->post->create( + array( + 'post_type' => 'no_notes', + 'post_author' => self::$editor_id, + ) + ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( + array( + 'post' => $unsupported_id, + 'content' => 'Unsupported.', + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + unregister_post_type( 'no_notes' ); + + $this->assertErrorResponse( 'rest_note_not_supported_post_type', $response, 403 ); + } + + /** + * Resolving a note posts no text of its own. + * + * @dataProvider data_resolution_statuses + * @covers ::check_is_comment_content_allowed + * + * @param string $status Resolution status stored in `_wp_note_status`. + */ + public function test_create_empty_note_with_resolution_meta( $status ) { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'post' => self::$post_id, + 'content' => '', + 'meta' => array( '_wp_note_status' => $status ), + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( $status, $response->get_data()['meta']['_wp_note_status'] ); + } + + /** + * Data provider for resolution statuses. + * + * @return array[] + */ + public function data_resolution_statuses() { + return array( + 'resolved' => array( 'resolved' ), + 'reopen' => array( 'reopen' ), + ); + } + + /** + * An empty note with nothing to record is still an empty note. + * + * @dataProvider data_disallowed_empty_note_meta + * @covers ::check_is_comment_content_allowed + * + * @param array $meta Meta sent with the note. + */ + public function test_cannot_create_empty_note( $meta ) { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->add_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array_merge( + array( + 'post' => self::$post_id, + 'content' => '', + ), + $meta + ) + ) + ); + + $this->assertErrorResponse( 'rest_comment_content_invalid', rest_get_server()->dispatch( $request ), 400 ); + } + + /** + * Data provider for empty notes that must be rejected. + * + * @return array[] + */ + public function data_disallowed_empty_note_meta() { + return array( + 'no meta at all' => array( array() ), + 'invalid status' => array( array( 'meta' => array( '_wp_note_status' => 'invalid' ) ) ), + ); + } + + /** + * Two people can raise the same point without one being swallowed. + * + * @covers ::determine_comment_approval + */ + public function test_duplicate_notes_are_both_created() { + wp_set_current_user( self::$editor_id ); + + for ( $i = 0; $i < 2; $i++ ) { + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( + array( + 'post' => self::$post_id, + 'content' => 'The same point, twice.', + ) + ); + + $this->assertSame( 201, rest_get_server()->dispatch( $request )->get_status() ); + } + + $this->assertCount( 2, $this->get_notes()->get_data() ); + } + + /** + * Reading a note follows edit access to its post, by role. + * + * @dataProvider data_note_read_permissions + * @covers ::get_items_permissions_check + * + * @param string $role Role of the reading user. + * @param string $post_author_role Role of the post author. + * @param bool $can_read Whether the reader should see the notes. + */ + public function test_note_read_permissions_by_role( $role, $post_author_role, $can_read ) { + $reader = self::factory()->user->create( array( 'role' => $role ) ); + $author = 'contributor' === $post_author_role + ? self::factory()->user->create( array( 'role' => 'contributor' ) ) + : self::factory()->user->create( array( 'role' => $post_author_role ) ); + + $post_id = self::factory()->post->create( + array( + 'post_author' => $author, + 'post_status' => 'contributor' === $post_author_role ? 'draft' : 'publish', + ) + ); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'note', + 'comment_approved' => '0', + 'user_id' => $author, + ) + ); + + wp_set_current_user( $reader ); + + $response = $this->get_notes( array( 'post' => $post_id ) ); + + if ( $can_read ) { + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $response->get_data() ); + } else { + $this->assertErrorResponse( 'rest_cannot_read_notes', $response, 403 ); + } + + wp_delete_post( $post_id, true ); + self::delete_user( $reader ); + self::delete_user( $author ); + } + + /** + * Data provider for note read permissions. + * + * @return array[] + */ + public function data_note_read_permissions() { + return array( + 'Administrator can see notes on other posts' => array( 'administrator', 'author', true ), + 'Editor can see notes on other posts' => array( 'editor', 'contributor', true ), + 'Author cannot see notes on other posts' => array( 'author', 'editor', false ), + 'Contributor cannot see notes on other posts' => array( 'contributor', 'author', false ), + 'Subscriber cannot see notes' => array( 'subscriber', 'author', false ), + ); + } + /** * The thread list does not build a `children` link per note. * From e0b48010a8a9e5902bbb542d95cf316b7d47348c Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 13 Aug 2026 16:32:46 -0700 Subject: [PATCH 4/5] REST API: Point the note mention notification tests at the notes route. These two exercise the rest_insert_comment wiring through HTTP, so they have to follow notes to wp/v2/notes now that the comments route no longer accepts the note type. --- tests/phpunit/tests/comment/wpNotifyNoteMentions.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/comment/wpNotifyNoteMentions.php b/tests/phpunit/tests/comment/wpNotifyNoteMentions.php index f8e1eddc75293..6e917a865af35 100644 --- a/tests/phpunit/tests/comment/wpNotifyNoteMentions.php +++ b/tests/phpunit/tests/comment/wpNotifyNoteMentions.php @@ -420,9 +420,8 @@ public function test_editing_a_note_does_not_renotify() { public function test_rest_note_creation_triggers_mention_email() { wp_set_current_user( self::$commenter->ID ); - $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request = new WP_REST_Request( 'POST', '/wp/v2/notes' ); $request->set_param( 'post', self::$post->ID ); - $request->set_param( 'type', 'note' ); $request->set_param( 'content', 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ) ); $response = rest_get_server()->dispatch( $request ); @@ -444,7 +443,7 @@ public function test_rest_note_update_does_not_renotify() { wp_set_current_user( self::$commenter->ID ); - $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $note->comment_ID ); + $request = new WP_REST_Request( 'PUT', '/wp/v2/notes/' . $note->comment_ID ); $request->set_param( 'content', 'Edited ping ' . $this->get_mention_markup( self::$mentioned->ID ) ); $response = rest_get_server()->dispatch( $request ); From de4fce64d31969588466ab6756655726ae9f0e0f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 14 Aug 2026 11:43:03 -0700 Subject: [PATCH 5/5] REST API: Leave resolution entries out of a note thread's reply count. Resolving or reopening a thread writes a reply of its own carrying `_wp_note_status`, so a thread nobody answered reported two replies once it had been resolved and reopened. Count only the replies someone wrote, while still returning the resolution entries in `replies` so the thread can render its history. --- .../class-wp-rest-notes-controller.php | 35 ++++++++++++++----- .../tests/rest-api/rest-notes-controller.php | 33 +++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php index 630984edc8ac3..4f67c6f191460 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-notes-controller.php @@ -333,7 +333,7 @@ public function get_item_schema() { ); $schema['properties']['reply_count'] = array( - 'description' => __( 'The number of replies in the thread.' ), + 'description' => __( 'The number of replies written in the thread, not counting the entries that record a resolution.' ), 'type' => 'integer', 'context' => array( 'view', 'edit' ), 'readonly' => true, @@ -560,21 +560,19 @@ protected function attach_replies( $response, $request ) { return $response; } - $replies_by_parent = $this->get_replies( $thread_ids, $request ); + list( $replies_by_parent, $counts_by_parent ) = $this->get_replies( $thread_ids, $request ); foreach ( $threads as $index => $thread ) { if ( ! isset( $thread['id'] ) ) { continue; } - $replies = isset( $replies_by_parent[ $thread['id'] ] ) ? $replies_by_parent[ $thread['id'] ] : array(); - if ( $want_replies ) { - $threads[ $index ]['replies'] = $replies; + $threads[ $index ]['replies'] = isset( $replies_by_parent[ $thread['id'] ] ) ? $replies_by_parent[ $thread['id'] ] : array(); } if ( $want_count ) { - $threads[ $index ]['reply_count'] = count( $replies ); + $threads[ $index ]['reply_count'] = isset( $counts_by_parent[ $thread['id'] ] ) ? $counts_by_parent[ $thread['id'] ] : 0; } } @@ -590,7 +588,12 @@ protected function attach_replies( $response, $request ) { * * @param int[] $thread_ids Top-level note IDs. * @param WP_REST_Request $request Full details about the request. - * @return array Prepared reply arrays keyed by parent note ID, oldest first. + * @return array { + * Two maps, both keyed by parent note ID. + * + * @type array $0 Prepared reply arrays, oldest first. + * @type array $1 Written reply counts. + * } */ protected function get_replies( $thread_ids, $request ) { $query = new WP_Comment_Query(); @@ -609,17 +612,31 @@ protected function get_replies( $thread_ids, $request ) { ); $replies_by_parent = array(); + $counts_by_parent = array(); foreach ( $replies as $reply ) { if ( ! $this->check_read_permission( $reply, $request ) ) { continue; } + $parent_id = (int) $reply->comment_parent; + $prepared = $this->prepare_item_for_response( $reply, $request ); - $replies_by_parent[ (int) $reply->comment_parent ][] = $this->prepare_response_for_collection( $prepared ); + $replies_by_parent[ $parent_id ][] = $this->prepare_response_for_collection( $prepared ); + + /* + * Resolving or reopening a thread records a reply of its own, marked + * with `_wp_note_status`. The thread needs those to render its + * history, but they are not something anyone wrote, so they stay out + * of the count a caller shows as "N replies". The meta cache is + * primed by the query above, so this costs no extra round trip. + */ + if ( '' === (string) get_comment_meta( $reply->comment_ID, '_wp_note_status', true ) ) { + $counts_by_parent[ $parent_id ] = isset( $counts_by_parent[ $parent_id ] ) ? $counts_by_parent[ $parent_id ] + 1 : 1; + } } - return $replies_by_parent; + return array( $replies_by_parent, $counts_by_parent ); } } diff --git a/tests/phpunit/tests/rest-api/rest-notes-controller.php b/tests/phpunit/tests/rest-api/rest-notes-controller.php index f787e9218e248..1cd1abd3dbb9e 100644 --- a/tests/phpunit/tests/rest-api/rest-notes-controller.php +++ b/tests/phpunit/tests/rest-api/rest-notes-controller.php @@ -409,6 +409,39 @@ public function test_reply_count_is_available_without_the_replies() { $this->assertSame( 1, $data[0]['reply_count'] ); } + /** + * Resolving and reopening a thread each record a reply of their own, and + * neither counts as a reply someone wrote. + * + * @covers ::attach_replies + */ + public function test_reply_count_ignores_resolution_entries() { + $thread = $this->create_note(); + $this->create_note( + array( + 'comment_parent' => $thread, + 'comment_content' => 'A written reply.', + ) + ); + + $resolved = $this->create_note( array( 'comment_parent' => $thread ) ); + update_comment_meta( $resolved, '_wp_note_status', 'resolved' ); + + $reopened = $this->create_note( array( 'comment_parent' => $thread ) ); + update_comment_meta( $reopened, '_wp_note_status', 'reopen' ); + + wp_set_current_user( self::$editor_id ); + + $data = $this->get_notes()->get_data(); + + $this->assertSame( 1, $data[0]['reply_count'], 'Only the written reply should be counted.' ); + $this->assertCount( + 3, + $data[0]['replies'], + 'The thread still needs the resolution entries to render its history.' + ); + } + /** * The collection is scoped to a post. *