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 f5739e6b3a3f3..1be401de3b975 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 @@ -167,6 +167,11 @@ public function get_items_permissions_check( $request ) { array( 'status' => rest_authorization_required_code() ) ); } elseif ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) { + /* + * The global primitive gates the ?post=0 collection, which can mix + * comment types. Orphans of an independent type are filtered out + * per comment, so a type's own moderators are rejected here. + */ return new WP_Error( 'rest_cannot_read', __( 'Sorry, you are not allowed to read comments without a post.' ), @@ -218,6 +223,7 @@ public function get_items_permissions_check( $request ) { } } } elseif ( $is_edit_context && ! current_user_can( 'moderate_comments' ) ) { + // The global primitive gates an edit-context collection that can mix comment types. return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit comments.' ), @@ -473,6 +479,7 @@ protected function get_comment( $id ) { * Checks if a given request has access to read the comment. * * @since 4.7.0 + * @since 7.3.0 The edit context is gated by the `moderate_comment` meta capability. * * @param WP_REST_Request $request Full details about the request. * @return true|WP_Error True if the request has read access for the item, error object otherwise. @@ -483,8 +490,8 @@ public function get_item_permissions_check( $request ) { return $comment; } - // Re-map edit context capabilities when requesting `note` or `reaction` type. - $edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comments' ); + // Match check_edit_permission(): notes and reactions need `edit_comment`, other types `moderate_comment`. + $edit_cap = in_array( $comment->comment_type, array( 'note', 'reaction' ), true ) ? array( 'edit_comment', $comment->comment_ID ) : array( 'moderate_comment', $comment->comment_ID ); if ( ! empty( $request['context'] ) && 'edit' === $request['context'] && ! current_user_can( ...$edit_cap ) ) { return new WP_Error( 'rest_forbidden_context', @@ -2401,6 +2408,7 @@ protected function prefetch_reaction_summaries( $note_ids ) { * Checks if the comment can be read. * * @since 4.7.0 + * @since 7.3.0 Orphaned comments are gated by the `moderate_comment` meta capability. * * @param WP_Comment $comment Comment object. * @param WP_REST_Request $request Request data to check. @@ -2420,7 +2428,7 @@ protected function check_read_permission( $comment, $request ) { return false; } - if ( empty( $comment->comment_post_ID ) && ! current_user_can( 'moderate_comments' ) ) { + if ( empty( $comment->comment_post_ID ) && ! current_user_can( 'moderate_comment', $comment->comment_ID ) ) { return false; } @@ -2435,6 +2443,7 @@ protected function check_read_permission( $comment, $request ) { * Checks if a comment can be edited or deleted. * * @since 4.7.0 + * @since 7.3.0 Moderators are checked with the `moderate_comment` meta capability. * * @param WP_Comment $comment Comment object. * @return bool Whether the comment can be edited or deleted. @@ -2452,7 +2461,8 @@ protected function check_edit_permission( $comment ) { return current_user_can( 'edit_comment', $comment->comment_ID ); } - if ( current_user_can( 'moderate_comments' ) ) { + // Resolves to the type's own moderation primitive, or `moderate_comments` for the default model. + if ( current_user_can( 'moderate_comment', $comment->comment_ID ) ) { return true; } diff --git a/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php new file mode 100644 index 0000000000000..b45b5ad17026d --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php @@ -0,0 +1,601 @@ +post->create(); + self::$admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); + + $user = $factory->user->create_and_get( array( 'role' => 'subscriber' ) ); + $user->add_cap( 'moderate_comments' ); + self::$global_moderator_id = $user->ID; + + $user = $factory->user->create_and_get( array( 'role' => 'subscriber' ) ); + $user->add_cap( 'moderate_reviews' ); + self::$review_moderator_id = $user->ID; + + $user = $factory->user->create_and_get( array( 'role' => 'subscriber' ) ); + $user->add_cap( 'edit_others_reviews' ); + self::$review_editor_id = $user->ID; + } + + public function set_up(): void { + parent::set_up(); + + // A comment type with an independent capability model. + register_comment_type( 'review', array( 'capability_type' => 'review' ) ); + } + + /** + * Creates a comment of the given type on the shared post. + * + * @param string $comment_type Comment type slug. + * @param int $post_id Optional. Post to attach the comment to; 0 for an + * orphaned comment. Default null (the shared post). + * @return int The new comment ID. + */ + private function make_comment( string $comment_type, ?int $post_id = null ): int { + return self::factory()->comment->create( + array( + 'comment_post_ID' => null === $post_id ? self::$post_id : $post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + ) + ); + } + + /** + * Dispatches a REST request to update a comment's content. + * + * @param int $comment_id Comment to update. + * @return WP_REST_Response + */ + private function dispatch_update( int $comment_id ): WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/wp/v2/comments/' . $comment_id ); + $request->set_param( 'content', 'Updated content' ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * Dispatches a REST request to force-delete a comment. + * + * @param int $comment_id Comment to delete. + * @return WP_REST_Response + */ + private function dispatch_delete( int $comment_id ): WP_REST_Response { + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $comment_id ); + $request->set_param( 'force', true ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * Dispatches a REST request to read a single comment. + * + * @param int $comment_id Comment to read. + * @param string $context Optional. Request context. Default 'view'. + * @return WP_REST_Response + */ + private function dispatch_get( int $comment_id, string $context = 'view' ): WP_REST_Response { + $request = new WP_REST_Request( 'GET', '/wp/v2/comments/' . $comment_id ); + $request->set_param( 'context', $context ); + + return rest_get_server()->dispatch( $request ); + } + + /* + * Independent capability model. + */ + + /** + * @ticket 35214 + */ + public function test_review_moderator_can_update_review_comment(): void { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'review' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_review_moderator_can_delete_review_comment(): void { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_delete( $this->make_comment( 'review' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * The type's `edit_others_reviews` primitive also grants moderation through the + * `edit_comment` fallback in `check_edit_permission()`. + * + * @ticket 35214 + */ + public function test_review_editor_can_update_review_comment(): void { + wp_set_current_user( self::$review_editor_id ); + + $response = $this->dispatch_update( $this->make_comment( 'review' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * A global `moderate_comments` moderator has no power over an independent type. + * + * @ticket 35214 + */ + public function test_global_moderator_cannot_update_review_comment(): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'review' ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * @ticket 35214 + */ + public function test_global_moderator_cannot_delete_review_comment(): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_delete( $this->make_comment( 'review' ) ); + + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + } + + /** + * A review moderator has no power over a default-model comment. + * + * @ticket 35214 + */ + public function test_review_moderator_cannot_update_default_comment(): void { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'comment' ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /** + * Dispatches a REST request to move a comment to another post. + * + * @param int $comment_id Comment to move. + * @param int $post_id Destination post. + * @return WP_REST_Response + */ + private function dispatch_move( int $comment_id, int $post_id ): WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/wp/v2/comments/' . $comment_id ); + $request->set_param( 'post', $post_id ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * Only the site-wide `moderate_comments` primitive skips the destination + * check, so a type moderator cannot move a comment onto a post they cannot + * edit, such as another user's private post. + * + * @ticket 35214 + */ + public function test_review_moderator_cannot_move_review_comment_to_uneditable_post(): void { + $comment_id = $this->make_comment( 'review' ); + $target_id = self::factory()->post->create( + array( + 'post_status' => 'private', + 'post_author' => self::$admin_id, + ) + ); + + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_move( $comment_id, $target_id ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + $comment = get_comment( $comment_id ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + $this->assertSame( (string) self::$post_id, $comment->comment_post_ID, 'The comment should stay on its original post.' ); + } + + /** + * @ticket 35214 + */ + public function test_review_moderator_can_move_review_comment_to_editable_post(): void { + $moderator = self::factory()->user->create_and_get( array( 'role' => 'author' ) ); + $moderator->add_cap( 'moderate_reviews' ); + $comment_id = $this->make_comment( 'review' ); + $target_id = self::factory()->post->create( array( 'post_author' => $moderator->ID ) ); + + wp_set_current_user( $moderator->ID ); + + $response = $this->dispatch_move( $comment_id, $target_id ); + + $this->assertSame( 200, $response->get_status() ); + $comment = get_comment( $comment_id ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + $this->assertSame( (string) $target_id, $comment->comment_post_ID ); + } + + /* + * Default capability model: unchanged from historical behavior. + */ + + /** + * @ticket 35214 + */ + public function test_global_moderator_can_update_default_comment(): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'comment' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_global_moderator_can_delete_default_comment(): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_delete( $this->make_comment( 'comment' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * A site-wide moderator may still move a default comment to any post. + * + * @ticket 35214 + */ + public function test_global_moderator_can_move_default_comment_to_another_post(): void { + $comment_id = $this->make_comment( 'comment' ); + $target_id = self::factory()->post->create( array( 'post_status' => 'private' ) ); + + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_move( $comment_id, $target_id ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * The built-in pingback type uses the default model and is unaffected. + * + * @ticket 35214 + */ + public function test_global_moderator_can_update_builtin_ping_comment(): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'pingback' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * An UNREGISTERED comment type stays fully moderatable by a global moderator. + * + * This pins the back-compat fallback everything relies on: comments of types + * that were never registered (e.g. stored by plugins before registration + * existed, or after their plugin is deactivated) resolve to a null type + * object and behave exactly like default comments. + * + * @ticket 35214 + */ + public function test_global_moderator_retains_control_over_unregistered_type(): void { + wp_set_current_user( self::$global_moderator_id ); + + $webmention_id = $this->make_comment( 'webmention' ); + + $response = $this->dispatch_update( $webmention_id ); + $this->assertSame( 200, $response->get_status(), 'A global moderator should be able to update an unregistered-type comment.' ); + + $response = $this->dispatch_delete( $webmention_id ); + $this->assertSame( 200, $response->get_status(), 'A global moderator should be able to delete an unregistered-type comment.' ); + } + + /** + * A comment stored with the legacy empty string type behaves like a default comment. + * + * @ticket 35214 + */ + public function test_global_moderator_can_update_legacy_empty_type_comment(): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( '' ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /* + * Single-item edit-context reads (get_item_permissions_check()): the read gate + * matches the update/delete gates, whose responses already return edit data. + */ + + /** + * @ticket 35214 + */ + public function test_review_moderator_can_read_review_comment_in_edit_context(): void { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_get( $this->make_comment( 'review' ), 'edit' ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_global_moderator_cannot_read_review_comment_in_edit_context(): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_get( $this->make_comment( 'review' ), 'edit' ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + /** + * @ticket 35214 + */ + public function test_global_moderator_can_read_default_comment_in_edit_context(): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_get( $this->make_comment( 'comment' ), 'edit' ); + + $this->assertSame( 200, $response->get_status() ); + } + + /* + * Orphaned comments (comment_post_ID = 0) through check_read_permission(). + */ + + /** + * Reading an orphaned default comment requires passing both the orphan gate + * (moderate_comment) and the final edit_comment check, which for orphans + * falls back to edit_posts - so administrators pass and bare moderators do + * not, exactly as before this change. + * + * @ticket 35214 + */ + public function test_admin_can_read_orphaned_default_comment(): void { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_get( $this->make_comment( 'comment', 0 ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * @ticket 35214 + */ + public function test_subscriber_cannot_read_orphaned_default_comment(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $response = $this->dispatch_get( $this->make_comment( 'comment', 0 ) ); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * Reading an orphaned independent-type comment requires the type's own + * capabilities on both checks: its moderation primitive for the orphan gate + * and its edit primitives for the final edit_comment check. + * + * @ticket 35214 + */ + public function test_review_moderator_with_edit_cap_can_read_orphaned_review_comment(): void { + $full_review_moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $full_review_moderator->add_cap( 'moderate_reviews' ); + $full_review_moderator->add_cap( 'edit_others_reviews' ); + + wp_set_current_user( $full_review_moderator->ID ); + + $response = $this->dispatch_get( $this->make_comment( 'review', 0 ) ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Even an administrator (global moderate_comments and edit_posts) cannot + * read an orphaned comment of an independent type. + * + * @ticket 35214 + */ + public function test_admin_cannot_read_orphaned_review_comment(): void { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_get( $this->make_comment( 'review', 0 ) ); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * The `?post=0` collection gate stays on the global primitive: a global + * moderator gets the list with independent-type orphans filtered out, while + * a type moderator is rejected at the gate. This pins the known, documented + * divergence until a per-type collection treatment exists. + * + * @ticket 35214 + */ + public function test_orphan_collection_gate_uses_global_primitive(): void { + $default_orphan = $this->make_comment( 'comment', 0 ); + $review_orphan = $this->make_comment( 'review', 0 ); + + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'post', 0 ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $ids = wp_list_pluck( $data, 'id' ); + $this->assertContains( $default_orphan, $ids, 'The default-type orphan should be listed for an administrator.' ); + $this->assertNotContains( $review_orphan, $ids, 'The independent-type orphan should be filtered out for an administrator.' ); + + wp_set_current_user( self::$review_moderator_id ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'post', 0 ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /* + * Notes and reactions keep trunk behavior: `edit_comment`, not moderation, gates them. + */ + + /** + * Creates a note on the shared post, or a reaction to one, authored by the given user. + * + * @param string $comment_type Either 'note' or 'reaction'. + * @param int $user_id Comment author. + * @return int The new comment ID. + */ + private function make_editorial_comment( string $comment_type, int $user_id ): int { + $args = array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '1', + 'user_id' => $user_id, + ); + + if ( 'reaction' === $comment_type ) { + // An approved note is a resolved thread, whose reactions are frozen. + $args['comment_parent'] = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => 'note', + 'comment_approved' => '0', + 'user_id' => self::$admin_id, + ) + ); + } + + return self::factory()->comment->create( $args ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_editorial_comment_types(): array { + return array( + 'note' => array( 'note' ), + 'reaction' => array( 'reaction' ), + ); + } + + /** + * A global moderator who cannot edit the post cannot read their own note or + * reaction in the edit context. + * + * @ticket 35214 + * + * @dataProvider data_editorial_comment_types + */ + public function test_global_moderator_cannot_read_editorial_comment_in_edit_context( string $comment_type ): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_get( $this->make_editorial_comment( $comment_type, self::$global_moderator_id ), 'edit' ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + /** + * @ticket 35214 + * + * @dataProvider data_editorial_comment_types + */ + public function test_post_editor_can_read_editorial_comment_in_edit_context( string $comment_type ): void { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_get( $this->make_editorial_comment( $comment_type, self::$admin_id ), 'edit' ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * A global moderator who cannot edit the post cannot delete their own note or + * reaction. + * + * @ticket 35214 + * + * @dataProvider data_editorial_comment_types + */ + public function test_global_moderator_cannot_delete_editorial_comment( string $comment_type ): void { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_delete( $this->make_editorial_comment( $comment_type, self::$global_moderator_id ) ); + + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + } + + /** + * @ticket 35214 + * + * @dataProvider data_editorial_comment_types + */ + public function test_post_editor_can_delete_own_editorial_comment( string $comment_type ): void { + wp_set_current_user( self::$admin_id ); + + $response = $this->dispatch_delete( $this->make_editorial_comment( $comment_type, self::$admin_id ) ); + + $this->assertSame( 200, $response->get_status() ); + } +}