From a0bf9705a9822aab7a7f4f32d692c42e3a5b86c1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 23:35:56 -0700 Subject: [PATCH 1/2] Comments: Make admin comment dimming comment-type aware. `wp_ajax_dim_comment()` fell back to the global `moderate_comments` primitive when a user could not `edit_comment` the target comment, so a moderator of a custom comment type could not approve or unapprove comments of that type from the admin list table. Route the fallback through the per-comment `moderate_comment` meta capability. For comment types using the default capability model this resolves to `moderate_comments` (behavior unchanged); a type with its own capabilities is gated by its own moderation primitive. This is the last per-comment moderation gate in the admin AJAX path. The remaining `moderate_comments` checks in the list table (bulk action availability, the Empty Spam/Trash button) and in XML-RPC `wp.getComments` are collection-level, not per-comment, and intentionally stay global. See #35214. --- src/wp-admin/includes/ajax-actions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 2af08fba70af9..f7cc58874ab94 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -997,7 +997,7 @@ function wp_ajax_dim_comment() { $response->send(); } - if ( ! current_user_can( 'edit_comment', $comment->comment_ID ) && ! current_user_can( 'moderate_comments' ) ) { + if ( ! current_user_can( 'edit_comment', $comment->comment_ID ) && ! current_user_can( 'moderate_comment', $comment->comment_ID ) ) { wp_die( -1 ); } From 99a0564f600c8aa38cb89ad37bf945f7f9a9af7b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 23:35:56 -0700 Subject: [PATCH 2/2] Comments: Add admin dim-comment tests for type-aware moderation. Drive `wp_ajax_dim_comment()` through the Ajax test harness: a moderator of an independent `review` type can dim its comments, while a global `moderate_comments` moderator without the type's capabilities is denied. The existing administrator and subscriber tests continue to pass, confirming the default model is unchanged. See #35214. --- tests/phpunit/tests/ajax/wpAjaxDimComment.php | 95 ++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/ajax/wpAjaxDimComment.php b/tests/phpunit/tests/ajax/wpAjaxDimComment.php index 2fde579605bb2..2b6ae87a483c6 100644 --- a/tests/phpunit/tests/ajax/wpAjaxDimComment.php +++ b/tests/phpunit/tests/ajax/wpAjaxDimComment.php @@ -25,14 +25,30 @@ class Tests_Ajax_wpAjaxDimComment extends WP_Ajax_UnitTestCase { */ protected $_comments = array(); + /** + * Post the comments are attached to. + * + * @var int + */ + protected $_post_id; + /** * Sets up the test fixture. */ public function set_up() { parent::set_up(); - $post_id = self::factory()->post->create(); - $this->_comments = self::factory()->comment->create_post_comments( $post_id, 15 ); + $this->_post_id = self::factory()->post->create(); + $this->_comments = self::factory()->comment->create_post_comments( $this->_post_id, 15 ); $this->_comments = array_map( 'get_comment', $this->_comments ); + + // A comment type with an independent capability model. + register_comment_type( 'review', array( 'capability_type' => 'review' ) ); + } + + public function tear_down() { + unregister_comment_type( 'review' ); + + parent::tear_down(); } /** @@ -239,4 +255,79 @@ public function test_ajax_dim_comment_bad_nonce() { $comment = array_pop( $this->_comments ); $this->_test_with_bad_nonce( $comment ); } + + /** + * Creates an unapproved comment of a given type on the shared post. + * + * @param string $comment_type Comment type slug. + * @return int The new comment ID. + */ + private function make_comment( $comment_type ) { + return self::factory()->comment->create( + array( + 'comment_post_ID' => $this->_post_id, + 'comment_type' => $comment_type, + 'comment_approved' => '0', + ) + ); + } + + /** + * Sets up the dim-comment request for the given comment. + * + * @param int $comment_id Comment to dim. + */ + private function set_up_dim_request( $comment_id ) { + $this->_clear_post_action(); + + $_POST['id'] = $comment_id; + $_POST['_ajax_nonce'] = wp_create_nonce( 'approve-comment_' . $comment_id ); + $_POST['_total'] = 1; + $_POST['_per_page'] = 100; + $_POST['_page'] = 1; + $_POST['_url'] = admin_url( 'edit-comments.php' ); + } + + /** + * A moderator of an independent comment type can dim its comments. + * + * @ticket 35214 + */ + public function test_dim_review_comment_as_type_moderator_is_allowed() { + $comment_id = $this->make_comment( 'review' ); + + $moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $moderator->add_cap( 'moderate_reviews' ); + wp_set_current_user( $moderator->ID ); + + $this->set_up_dim_request( $comment_id ); + + try { + $this->_handleAjax( 'dim-comment' ); + } catch ( WPAjaxDieContinueException $e ) { + unset( $e ); + } + + // The request was authorized, so the unapproved comment was approved. + $this->assertSame( 'approved', wp_get_comment_status( $comment_id ) ); + } + + /** + * A global moderator without the type's capabilities cannot dim its comments. + * + * @ticket 35214 + */ + public function test_dim_review_comment_as_global_moderator_is_denied() { + $comment_id = $this->make_comment( 'review' ); + + $global_moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $global_moderator->add_cap( 'moderate_comments' ); + wp_set_current_user( $global_moderator->ID ); + + $this->set_up_dim_request( $comment_id ); + + $this->expectException( 'WPAjaxDieStopException' ); + $this->expectExceptionMessage( '-1' ); + $this->_handleAjax( 'dim-comment' ); + } }