diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 028e61ec414a8..1fc9a4b8bda93 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -572,16 +572,105 @@ function map_meta_cap( $cap, $user_id, ...$args ) { break; } - $post = get_post( $comment->comment_post_ID ); + $comment_type = get_comment_type_object( $comment->comment_type ); /* - * If the post doesn't exist, we have an orphaned comment. - * Fall back to the edit_posts capability, instead. + * Comment types using the default 'comment' capability model derive edit + * permission from the comment's parent post, preserving historical behavior. + * A registered type that opts into its own capabilities (its mapped + * `edit_comment` differs from the generic meta capability) is gated by those + * primitives instead, mirroring how registered post types map `edit_post`. */ - if ( $post ) { - $caps = map_meta_cap( 'edit_post', $user_id, $post->ID ); + if ( ! $comment_type || 'edit_comment' === $comment_type->cap->edit_comment ) { + $post = get_post( $comment->comment_post_ID ); + + /* + * If the post doesn't exist, we have an orphaned comment. + * Fall back to the edit_posts capability, instead. + */ + if ( $post ) { + $caps = map_meta_cap( 'edit_post', $user_id, $post->ID ); + } else { + $caps = map_meta_cap( 'edit_posts', $user_id ); + } + } elseif ( $comment->user_id && (int) $comment->user_id === (int) $user_id ) { + $caps[] = $comment_type->cap->edit_comments; + } else { + $caps[] = $comment_type->cap->edit_others_comments; + } + break; + case 'delete_comment': + if ( ! isset( $args[0] ) ) { + /* translators: %s: Capability name. */ + $message = __( 'When checking for the %s capability, you must always check it against a specific comment.' ); + + _doing_it_wrong( + __FUNCTION__, + sprintf( $message, '' . $cap . '' ), + '7.1.0' + ); + + $caps[] = 'do_not_allow'; + break; + } + + $comment = get_comment( $args[0] ); + if ( ! $comment ) { + $caps[] = 'do_not_allow'; + break; + } + + $comment_type = get_comment_type_object( $comment->comment_type ); + + /* + * As with editing, deletion of a default-model comment follows the + * comment's parent post. A type with its own capabilities is gated by its + * `delete_comments` primitive. + */ + if ( ! $comment_type || 'delete_comment' === $comment_type->cap->delete_comment ) { + $post = get_post( $comment->comment_post_ID ); + + if ( $post ) { + $caps = map_meta_cap( 'edit_post', $user_id, $post->ID ); + } else { + $caps = map_meta_cap( 'edit_posts', $user_id ); + } + } else { + $caps[] = $comment_type->cap->delete_comments; + } + break; + case 'moderate_comment': + if ( ! isset( $args[0] ) ) { + /* translators: %s: Capability name. */ + $message = __( 'When checking for the %s capability, you must always check it against a specific comment.' ); + + _doing_it_wrong( + __FUNCTION__, + sprintf( $message, '' . $cap . '' ), + '7.1.0' + ); + + $caps[] = 'do_not_allow'; + break; + } + + $comment = get_comment( $args[0] ); + if ( ! $comment ) { + $caps[] = 'do_not_allow'; + break; + } + + /* + * Moderating a default-model comment requires the global `moderate_comments` + * primitive, exactly as today. A type with its own capabilities is gated by + * its `moderate_comments` primitive (e.g. `moderate_reviews`). + */ + $comment_type = get_comment_type_object( $comment->comment_type ); + + if ( $comment_type ) { + $caps[] = $comment_type->cap->moderate_comments; } else { - $caps = map_meta_cap( 'edit_posts', $user_id ); + $caps[] = 'moderate_comments'; } break; case 'unfiltered_upload': diff --git a/tests/phpunit/tests/comment/commentCapabilities.php b/tests/phpunit/tests/comment/commentCapabilities.php new file mode 100644 index 0000000000000..001813edf969b --- /dev/null +++ b/tests/phpunit/tests/comment/commentCapabilities.php @@ -0,0 +1,185 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + self::$post_id = $factory->post->create( array( 'post_author' => self::$admin_id ) ); + } + + public function set_up() { + parent::set_up(); + + // 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(); + } + + /** + * Creates a comment of the given type, optionally attributed to a user. + * + * @param string $comment_type Comment type slug. + * @param int $user_id Authoring user ID. Default 0 (anonymous). + * @return int The new comment ID. + */ + private function make_comment( $comment_type = 'comment', $user_id = 0 ) { + return self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => $comment_type, + 'user_id' => $user_id, + ) + ); + } + + /* + * Default capability model: behavior must match historical core exactly. + */ + + /** + * @ticket 35214 + */ + public function test_default_edit_comment_follows_parent_post() { + $comment_id = $this->make_comment(); + + $this->assertTrue( user_can( self::$admin_id, 'edit_comment', $comment_id ) ); + $this->assertFalse( user_can( self::$subscriber_id, 'edit_comment', $comment_id ) ); + } + + /** + * @ticket 35214 + */ + public function test_orphaned_comment_edit_falls_back_to_edit_posts() { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => 0, + 'comment_type' => 'comment', + ) + ); + + $this->assertTrue( user_can( self::$admin_id, 'edit_comment', $comment_id ) ); + $this->assertFalse( user_can( self::$subscriber_id, 'edit_comment', $comment_id ) ); + } + + /** + * @ticket 35214 + */ + public function test_default_moderate_comment_requires_moderate_comments() { + $comment_id = $this->make_comment(); + + $this->assertTrue( user_can( self::$admin_id, 'moderate_comment', $comment_id ) ); + $this->assertFalse( user_can( self::$subscriber_id, 'moderate_comment', $comment_id ) ); + } + + /** + * @ticket 35214 + */ + public function test_default_delete_comment_follows_parent_post() { + $comment_id = $this->make_comment(); + + $this->assertTrue( user_can( self::$admin_id, 'delete_comment', $comment_id ) ); + $this->assertFalse( user_can( self::$subscriber_id, 'delete_comment', $comment_id ) ); + } + + /** + * @ticket 35214 + * + * @expectedIncorrectUsage map_meta_cap + */ + public function test_moderate_comment_without_comment_id_is_denied() { + $this->assertFalse( user_can( self::$admin_id, 'moderate_comment' ) ); + } + + /* + * Independent capability model: gated by the type's own primitives, with no + * silent fallback to the default comment capabilities. + */ + + /** + * @ticket 35214 + */ + public function test_independent_type_moderation_requires_its_own_primitive() { + $review_id = $this->make_comment( 'review' ); + $comment_id = $this->make_comment( 'comment' ); + + $moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $moderator->add_cap( 'moderate_reviews' ); + + // The review moderator can moderate reviews but not default comments. + $this->assertTrue( user_can( $moderator->ID, 'moderate_comment', $review_id ) ); + $this->assertFalse( user_can( $moderator->ID, 'moderate_comment', $comment_id ) ); + + // An administrator has moderate_comments but not moderate_reviews: the inverse. + $this->assertTrue( user_can( self::$admin_id, 'moderate_comment', $comment_id ) ); + $this->assertFalse( user_can( self::$admin_id, 'moderate_comment', $review_id ) ); + } + + /** + * @ticket 35214 + */ + public function test_independent_type_edit_distinguishes_own_and_others() { + $author = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $author->add_cap( 'edit_reviews' ); + + $own_review = $this->make_comment( 'review', $author->ID ); + $others_review = $this->make_comment( 'review', self::$admin_id ); + + // edit_reviews grants editing one's own review comments only. + $this->assertTrue( user_can( $author->ID, 'edit_comment', $own_review ) ); + $this->assertFalse( user_can( $author->ID, 'edit_comment', $others_review ) ); + + // edit_others_reviews grants editing any review comment. + $editor = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $editor->add_cap( 'edit_others_reviews' ); + + $this->assertTrue( user_can( $editor->ID, 'edit_comment', $others_review ) ); + } + + /** + * @ticket 35214 + */ + public function test_independent_type_delete_requires_its_own_primitive() { + $review_id = $this->make_comment( 'review' ); + + $deleter = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $deleter->add_cap( 'delete_reviews' ); + + $this->assertTrue( user_can( $deleter->ID, 'delete_comment', $review_id ) ); + $this->assertFalse( user_can( self::$subscriber_id, 'delete_comment', $review_id ) ); + } +} diff --git a/tests/phpunit/tests/user/capabilities.php b/tests/phpunit/tests/user/capabilities.php index b92b0db231ecb..3aac321d2060a 100644 --- a/tests/phpunit/tests/user/capabilities.php +++ b/tests/phpunit/tests/user/capabilities.php @@ -552,6 +552,8 @@ public function testMetaCapsTestsAreCorrect() { $expected['delete_post_meta'], $expected['add_post_meta'], $expected['edit_comment'], + $expected['delete_comment'], + $expected['moderate_comment'], $expected['edit_comment_meta'], $expected['delete_comment_meta'], $expected['add_comment_meta'],