From a0bf9705a9822aab7a7f4f32d692c42e3a5b86c1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 23:35:56 -0700 Subject: [PATCH 1/6] 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/6] 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' ); + } } From 5b038ea9ca89130b4bb1750229942c6cd7e4e9fe Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 10:52:06 -0700 Subject: [PATCH 3/6] Comments: Round out type-aware dim-comment coverage and docs. - Add the missing @since 7.1.0 changelog entry to wp_ajax_dim_comment() and an inline rationale at the rerouted capability check, making the pattern self-documenting at every rerouted site. - Tests: the un-dim direction (dimming an approved comment unapproves it) for a type moderator, the inverse denial (a type moderator cannot dim a default-model comment), and the unregistered-type back-compat pin (a global moderator retains control of never-registered types via AJAX). Drop the cleanup-only unregister call now handled by the test framework. --- src/wp-admin/includes/ajax-actions.php | 8 ++ tests/phpunit/tests/ajax/wpAjaxDimComment.php | 73 +++++++++++++++++-- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index f7cc58874ab94..08899af2e738c 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -978,6 +978,8 @@ function wp_ajax_delete_page( $action ) { * Handles dimming a comment via AJAX. * * @since 3.1.0 + * @since 7.1.0 Uses the per-comment `moderate_comment` meta capability instead + * of the global `moderate_comments` primitive. */ function wp_ajax_dim_comment() { $id = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0; @@ -997,6 +999,12 @@ function wp_ajax_dim_comment() { $response->send(); } + /* + * Use the per-comment `moderate_comment` meta capability: it resolves to the + * global `moderate_comments` primitive for default-model and unregistered + * types (unchanged), and to the type's own moderation primitive for a + * registered type with its own capabilities. + */ if ( ! current_user_can( 'edit_comment', $comment->comment_ID ) && ! current_user_can( 'moderate_comment', $comment->comment_ID ) ) { wp_die( -1 ); } diff --git a/tests/phpunit/tests/ajax/wpAjaxDimComment.php b/tests/phpunit/tests/ajax/wpAjaxDimComment.php index 2b6ae87a483c6..54819b578da0b 100644 --- a/tests/phpunit/tests/ajax/wpAjaxDimComment.php +++ b/tests/phpunit/tests/ajax/wpAjaxDimComment.php @@ -45,12 +45,6 @@ public function set_up() { register_comment_type( 'review', array( 'capability_type' => 'review' ) ); } - public function tear_down() { - unregister_comment_type( 'review' ); - - parent::tear_down(); - } - /** * Clears the POST actions in between requests. */ @@ -330,4 +324,71 @@ public function test_dim_review_comment_as_global_moderator_is_denied() { $this->expectExceptionMessage( '-1' ); $this->_handleAjax( 'dim-comment' ); } + + /** + * A type moderator can also un-dim: dimming an approved comment unapproves it. + * + * @ticket 35214 + */ + public function test_dim_approved_review_comment_as_type_moderator_unapproves_it() { + $comment_id = $this->make_comment( 'review' ); + wp_set_comment_status( $comment_id, 'approve' ); + + $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 ); + } + + $this->assertSame( 'unapproved', wp_get_comment_status( $comment_id ) ); + } + + /** + * The inverse denial: a type moderator cannot dim a default-model comment. + * + * @ticket 35214 + */ + public function test_dim_default_comment_as_type_moderator_is_denied() { + $comment_id = $this->make_comment( 'comment' ); + + $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 ); + + $this->expectException( 'WPAjaxDieStopException' ); + $this->expectExceptionMessage( '-1' ); + $this->_handleAjax( 'dim-comment' ); + } + + /** + * A global moderator keeps full control over UNREGISTERED comment types + * (the null type object back-compat fallback). + * + * @ticket 35214 + */ + public function test_dim_unregistered_type_comment_as_global_moderator_is_allowed() { + $comment_id = $this->make_comment( 'webmention' ); + + $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 ); + + try { + $this->_handleAjax( 'dim-comment' ); + } catch ( WPAjaxDieContinueException $e ) { + unset( $e ); + } + + $this->assertSame( 'approved', wp_get_comment_status( $comment_id ) ); + } } From db75e210c2e6b61c6105774f31207f9d735aa513 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:34:56 -0500 Subject: [PATCH 4/6] Comments: Tighten wp_ajax_dim_comment() moderation docs. The `@since` entry named 7.1.0, but the change ships in 7.2.0, and the inline comment restated what the meta capability mapping already documents. No behavior change. See #35214. --- src/wp-admin/includes/ajax-actions.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 02646dba67d72..708302c74e7e1 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -926,8 +926,7 @@ function wp_ajax_delete_page( $action ) { * Handles dimming a comment via AJAX. * * @since 3.1.0 - * @since 7.1.0 Uses the per-comment `moderate_comment` meta capability instead - * of the global `moderate_comments` primitive. + * @since 7.2.0 Moderators are checked with the `moderate_comment` meta capability. */ function wp_ajax_dim_comment() { $id = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0; @@ -947,12 +946,7 @@ function wp_ajax_dim_comment() { $response->send(); } - /* - * Use the per-comment `moderate_comment` meta capability: it resolves to the - * global `moderate_comments` primitive for default-model and unregistered - * types (unchanged), and to the type's own moderation primitive for a - * registered type with its own capabilities. - */ + // Resolves to the type's own moderation primitive, or `moderate_comments` for the default model. if ( ! current_user_can( 'edit_comment', $comment->comment_ID ) && ! current_user_can( 'moderate_comment', $comment->comment_ID ) ) { wp_die( -1 ); } From 43c8778e2848ba2e9b1a25809e612d4c6b570824 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:34:56 -0500 Subject: [PATCH 5/6] Tests: Pin note and reaction dimming for global moderators. Trunk added the `reaction` type next to `note`. Neither registers its own capabilities, so `moderate_comment` resolves to `moderate_comments` and a global moderator can still dim them, as before. Pins that, and adds native types to the new helpers and tests so they pass PHPStan level 10. See #35214. --- tests/phpunit/tests/ajax/wpAjaxDimComment.php | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/tests/phpunit/tests/ajax/wpAjaxDimComment.php b/tests/phpunit/tests/ajax/wpAjaxDimComment.php index 54819b578da0b..888c7a64a5f0e 100644 --- a/tests/phpunit/tests/ajax/wpAjaxDimComment.php +++ b/tests/phpunit/tests/ajax/wpAjaxDimComment.php @@ -30,7 +30,7 @@ class Tests_Ajax_wpAjaxDimComment extends WP_Ajax_UnitTestCase { * * @var int */ - protected $_post_id; + protected int $_post_id; /** * Sets up the test fixture. @@ -256,7 +256,7 @@ public function test_ajax_dim_comment_bad_nonce() { * @param string $comment_type Comment type slug. * @return int The new comment ID. */ - private function make_comment( $comment_type ) { + private function make_comment( string $comment_type ): int { return self::factory()->comment->create( array( 'comment_post_ID' => $this->_post_id, @@ -271,7 +271,7 @@ private function make_comment( $comment_type ) { * * @param int $comment_id Comment to dim. */ - private function set_up_dim_request( $comment_id ) { + private function set_up_dim_request( int $comment_id ): void { $this->_clear_post_action(); $_POST['id'] = $comment_id; @@ -287,7 +287,7 @@ private function set_up_dim_request( $comment_id ) { * * @ticket 35214 */ - public function test_dim_review_comment_as_type_moderator_is_allowed() { + public function test_dim_review_comment_as_type_moderator_is_allowed(): void { $comment_id = $this->make_comment( 'review' ); $moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); @@ -311,7 +311,7 @@ public function test_dim_review_comment_as_type_moderator_is_allowed() { * * @ticket 35214 */ - public function test_dim_review_comment_as_global_moderator_is_denied() { + public function test_dim_review_comment_as_global_moderator_is_denied(): void { $comment_id = $this->make_comment( 'review' ); $global_moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); @@ -330,7 +330,7 @@ public function test_dim_review_comment_as_global_moderator_is_denied() { * * @ticket 35214 */ - public function test_dim_approved_review_comment_as_type_moderator_unapproves_it() { + public function test_dim_approved_review_comment_as_type_moderator_unapproves_it(): void { $comment_id = $this->make_comment( 'review' ); wp_set_comment_status( $comment_id, 'approve' ); @@ -354,7 +354,7 @@ public function test_dim_approved_review_comment_as_type_moderator_unapproves_it * * @ticket 35214 */ - public function test_dim_default_comment_as_type_moderator_is_denied() { + public function test_dim_default_comment_as_type_moderator_is_denied(): void { $comment_id = $this->make_comment( 'comment' ); $moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); @@ -369,12 +369,11 @@ public function test_dim_default_comment_as_type_moderator_is_denied() { } /** - * A global moderator keeps full control over UNREGISTERED comment types - * (the null type object back-compat fallback). + * A global moderator keeps full control over unregistered comment types. * * @ticket 35214 */ - public function test_dim_unregistered_type_comment_as_global_moderator_is_allowed() { + public function test_dim_unregistered_type_comment_as_global_moderator_is_allowed(): void { $comment_id = $this->make_comment( 'webmention' ); $global_moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); @@ -391,4 +390,41 @@ public function test_dim_unregistered_type_comment_as_global_moderator_is_allowe $this->assertSame( 'approved', wp_get_comment_status( $comment_id ) ); } + + /** + * Data provider. + * + * @return array + */ + public function data_editorial_comment_types(): array { + return array( + 'note' => array( 'note' ), + 'reaction' => array( 'reaction' ), + ); + } + + /** + * Notes and reactions use the default model, so a global moderator can still dim them. + * + * @ticket 35214 + * + * @dataProvider data_editorial_comment_types + */ + public function test_dim_editorial_comment_as_global_moderator_is_allowed( string $comment_type ): void { + $comment_id = $this->make_comment( $comment_type ); + + $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 ); + + try { + $this->_handleAjax( 'dim-comment' ); + } catch ( WPAjaxDieContinueException $e ) { + unset( $e ); + } + + $this->assertSame( 'approved', wp_get_comment_status( $comment_id ) ); + } } From ddd60bc3d116b145d6fbfdc247b1b27659037b1b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sun, 8 Nov 2026 09:36:21 -0500 Subject: [PATCH 6/6] Docs: Mark the new comment type APIs as @since 7.3.0. Trunk is in the 7.2 beta, so these additions can no longer ship in 7.2.0. --- 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 708302c74e7e1..49d684d8139df 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -926,7 +926,7 @@ function wp_ajax_delete_page( $action ) { * Handles dimming a comment via AJAX. * * @since 3.1.0 - * @since 7.2.0 Moderators are checked with the `moderate_comment` meta capability. + * @since 7.3.0 Moderators are checked with the `moderate_comment` meta capability. */ function wp_ajax_dim_comment() { $id = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0;