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 f462928847c77..ee36774df133d 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 @@ -139,6 +139,14 @@ public function get_items_permissions_check( $request ) { array( 'status' => rest_authorization_required_code() ) ); } elseif ( 0 === $post_id && ! current_user_can( 'moderate_comments' ) ) { + /* + * Intentionally the global primitive: this gates the whole ?post=0 + * collection, which can mix comment types, before any individual + * comment is known. Known divergence: a global moderator sees + * independent-type orphans filtered out of the list (empty 200) + * while a type moderator gets 403 here. A per-type collection + * treatment belongs with a future `type` parameter rework. + */ return new WP_Error( 'rest_cannot_read', __( 'Sorry, you are not allowed to read comments without a post.' ), @@ -190,6 +198,8 @@ public function get_items_permissions_check( $request ) { } } } elseif ( $is_edit_context && ! current_user_can( 'moderate_comments' ) ) { + // Intentionally the global primitive: gates an edit-context collection + // that can mix comment types, before any individual comment is known. return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit comments.' ), @@ -437,8 +447,13 @@ 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' ); + /* + * Re-map edit context capabilities per comment type: `note` comments are + * gated by `edit_comment`, all others by the per-comment `moderate_comment` + * meta capability. This matches the update and delete paths, whose responses + * already return edit-context data. + */ + $edit_cap = 'note' === $comment->comment_type ? 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', @@ -539,7 +554,12 @@ public function create_item_permissions_check( $request ) { } } - // Limit who can set comment `author`, `author_ip` or `status` to anything other than the default. + /* + * Limit who can set comment `author`, `author_ip` or `status` to anything + * other than the default. Intentionally the global primitive: these gates + * run at creation time, and honoring the (known) type's own moderation + * primitive here is future work alongside the other creation-time gates. + */ if ( isset( $request['author'] ) && get_current_user_id() !== $request['author'] && ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( 'rest_comment_invalid_author', @@ -1913,6 +1933,8 @@ protected function check_read_post_permission( $post, $request ) { * Checks if the comment can be read. * * @since 4.7.0 + * @since 7.1.0 Orphaned comments are gated by the per-comment `moderate_comment` + * meta capability instead of the global `moderate_comments` primitive. * * @param WP_Comment $comment Comment object. * @param WP_REST_Request $request Request data to check. @@ -1932,7 +1954,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; } @@ -1947,6 +1969,8 @@ protected function check_read_permission( $comment, $request ) { * Checks if a comment can be edited or deleted. * * @since 4.7.0 + * @since 7.1.0 Uses the per-comment `moderate_comment` meta capability instead + * of the global `moderate_comments` primitive. * * @param WP_Comment $comment Comment object. * @return bool Whether the comment can be edited or deleted. @@ -1956,7 +1980,13 @@ protected function check_edit_permission( $comment ) { return false; } - if ( current_user_can( 'moderate_comments' ) ) { + /* + * Use the per-comment `moderate_comment` meta capability rather than the + * global `moderate_comments` primitive. For comment types using the default + * capability model this resolves to `moderate_comments` (unchanged), while a + * type with its own capabilities is gated by its own moderation primitive. + */ + 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..882d55080a0f6 --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-comment-type-moderation.php @@ -0,0 +1,417 @@ +post->create(); + self::$admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); + + self::$global_moderator_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + get_userdata( self::$global_moderator_id )->add_cap( 'moderate_comments' ); + + self::$review_moderator_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + get_userdata( self::$review_moderator_id )->add_cap( 'moderate_reviews' ); + + self::$review_editor_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + get_userdata( self::$review_editor_id )->add_cap( 'edit_others_reviews' ); + } + + public function set_up() { + 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 the shared post. + * @return int The new comment ID. + */ + private function make_comment( $comment_type, $post_id = null ) { + 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( $comment_id ) { + $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( $comment_id ) { + $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( $comment_id, $context = 'view' ) { + $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() { + 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() { + 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() { + 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() { + 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() { + 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() { + wp_set_current_user( self::$review_moderator_id ); + + $response = $this->dispatch_update( $this->make_comment( 'comment' ) ); + + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + /* + * Default capability model: unchanged from historical behavior. + */ + + /** + * @ticket 35214 + */ + public function test_global_moderator_can_update_default_comment() { + 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() { + wp_set_current_user( self::$global_moderator_id ); + + $response = $this->dispatch_delete( $this->make_comment( 'comment' ) ); + + $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() { + 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() { + 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() { + 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() { + 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() { + 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() { + 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() { + 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() { + 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() { + $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() { + 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() { + $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() ); + $ids = wp_list_pluck( $response->get_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 ); + } +}