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/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 6f88c5e7b499d..b30e13f33d01c 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -94,6 +94,32 @@ final class WP_Comment_Type { */ public $show_ui; + /** + * The string to use to build the read, edit, and delete capabilities. + * + * May be passed as an array to allow for alternative plurals when using + * this argument as a base to construct the capabilities, e.g. + * array( 'story', 'stories' ). Default 'comment'. + * + * @since 7.1.0 + * @var string|array + */ + public $capability_type = 'comment'; + + /** + * Capabilities for this comment type. + * + * Built by {@see get_comment_type_capabilities()} from the + * `capability_type` and `capabilities` arguments. This is advisory metadata + * describing the capabilities associated with the comment type; the + * capability mapping in {@see map_meta_cap()} is not affected by this + * property in this release. + * + * @since 7.1.0 + * @var stdClass + */ + public $cap; + /** * Whether this comment type is a native or "built-in" comment type. * @@ -187,12 +213,14 @@ public function set_props( $args ) { * treated as a provided value and overwrite the default name with false. */ $defaults = array( - 'labels' => array(), - 'description' => '', - 'public' => true, - 'internal' => false, - 'show_ui' => null, - '_builtin' => false, + 'labels' => array(), + 'description' => '', + 'public' => true, + 'internal' => false, + 'show_ui' => null, + 'capability_type' => 'comment', + 'capabilities' => array(), + '_builtin' => false, ); $args = array_merge( $defaults, $args ); @@ -204,6 +232,15 @@ public function set_props( $args ) { $args['name'] = $this->name; + // Build the capabilities object, then remove the input array from the props. + $this->cap = get_comment_type_capabilities( (object) $args ); + unset( $args['capabilities'] ); + + // Collapse an array capability type back to its singular base. + if ( is_array( $args['capability_type'] ) ) { + $args['capability_type'] = $args['capability_type'][0]; + } + foreach ( $args as $property_name => $property_value ) { $this->$property_name = $property_value; } diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 2ebc55a1804b0..95c7a073f445f 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -365,8 +365,16 @@ function create_initial_comment_types() { * the admin interface or by front-end users. Default true. * @type bool $internal Whether the comment type is for internal use only and should be * excluded from default public-facing contexts. Default false. - * @type bool $show_ui Whether to generate and allow a UI for managing this comment type - * in the admin. Default is value of $public. + * @type bool $show_ui Whether to generate and allow a UI for managing this comment + * type in the admin. Default is value of $public. + * @type string|array $capability_type The string to use to build the read, edit, and delete + * capabilities. May be passed as an array to allow for + * alternative plurals when using this argument as a base to + * construct the capabilities, e.g. array( 'story', 'stories' ). + * Default 'comment'. + * @type string[] $capabilities Array of capabilities for this comment type. $capability_type + * is used as a base to construct capabilities by default. + * See get_comment_type_capabilities(). * } * @return WP_Comment_Type|WP_Error The registered comment type object on success, * WP_Error object on failure. @@ -566,6 +574,53 @@ function get_comment_type_labels( $comment_type_object ) { return $labels; } +/** + * Builds an object with all comment type capabilities out of a comment type object. + * + * Comment type capabilities use the `capability_type` argument as a base, if + * the capability is not set in the `capabilities` argument. + * + * This is advisory metadata describing the capabilities associated with a comment + * type, modeled on {@see get_post_type_capabilities()}. The capability mapping in + * {@see map_meta_cap()} is not affected by these capabilities in this release. + * + * The capability strings are built from the `capability_type` argument, which may + * be a string or an array. When a string, the plural is created by appending an + * 's'. When an array, the first element is the singular base and the second the + * plural base, e.g. array( 'story', 'stories' ). + * + * @since 7.1.0 + * + * @param object $args Comment type registration arguments. Expects the + * `capability_type` and `capabilities` properties. + * @return object Object with all the capabilities as member variables. + */ +function get_comment_type_capabilities( $args ) { + if ( ! is_array( $args->capability_type ) ) { + $args->capability_type = array( $args->capability_type, $args->capability_type . 's' ); + } + + // Singular base for meta capabilities, plural base for primitive capabilities. + list( $singular_base, $plural_base ) = $args->capability_type; + + $default_capabilities = array( + // Meta capabilities. + 'edit_comment' => 'edit_' . $singular_base, + 'read_comment' => 'read_' . $singular_base, + 'delete_comment' => 'delete_' . $singular_base, + 'moderate_comment' => 'moderate_' . $singular_base, + // Primitive capabilities used outside of map_meta_cap(). + 'edit_comments' => 'edit_' . $plural_base, + 'edit_others_comments' => 'edit_others_' . $plural_base, + 'delete_comments' => 'delete_' . $plural_base, + 'moderate_comments' => 'moderate_' . $plural_base, + ); + + $capabilities = array_merge( $default_capabilities, $args->capabilities ); + + return (object) $capabilities; +} + /** * Retrieves all of the WordPress supported comment statuses. * 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/comment/types.php b/tests/phpunit/tests/comment/types.php index 54f8e2aa9ad33..dd7fa6aaa4c7b 100644 --- a/tests/phpunit/tests/comment/types.php +++ b/tests/phpunit/tests/comment/types.php @@ -291,4 +291,66 @@ static function ( $labels ) { $this->assertSame( 'Filtered Foo', get_comment_type_object( 'foo' )->labels->singular_name ); } + + /** + * @ticket 35214 + */ + public function test_registered_comment_type_exposes_cap_object() { + register_comment_type( 'foo', array( 'capability_type' => 'review' ) ); + + $cobj = get_comment_type_object( 'foo' ); + + $this->assertSame( 'edit_reviews', $cobj->cap->edit_comments ); + $this->assertSame( 'moderate_reviews', $cobj->cap->moderate_comments ); + } + + /** + * The built-in comment type's capabilities match the existing core comment capabilities. + * + * @ticket 35214 + */ + public function test_built_in_comment_type_capabilities_are_backward_compatible() { + $cobj = get_comment_type_object( 'comment' ); + + $this->assertSame( 'edit_comment', $cobj->cap->edit_comment ); + $this->assertSame( 'moderate_comments', $cobj->cap->moderate_comments ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_type_capabilities + */ + public function test_get_comment_type_capabilities_from_string() { + $caps = get_comment_type_capabilities( + (object) array( + 'capability_type' => 'review', + 'capabilities' => array(), + ) + ); + + $this->assertSame( 'edit_review', $caps->edit_comment ); + $this->assertSame( 'edit_reviews', $caps->edit_comments ); + $this->assertSame( 'edit_others_reviews', $caps->edit_others_comments ); + $this->assertSame( 'delete_review', $caps->delete_comment ); + $this->assertSame( 'moderate_reviews', $caps->moderate_comments ); + } + + /** + * @ticket 35214 + * + * @covers ::get_comment_type_capabilities + */ + public function test_get_comment_type_capabilities_honors_capabilities_override() { + $caps = get_comment_type_capabilities( + (object) array( + 'capability_type' => 'comment', + 'capabilities' => array( + 'edit_comments' => 'manage_stuff', + ), + ) + ); + + $this->assertSame( 'manage_stuff', $caps->edit_comments ); + } } diff --git a/tests/phpunit/tests/comment/wpCommentType.php b/tests/phpunit/tests/comment/wpCommentType.php index bf6d3c7df28dd..8429c69f88b15 100644 --- a/tests/phpunit/tests/comment/wpCommentType.php +++ b/tests/phpunit/tests/comment/wpCommentType.php @@ -117,4 +117,81 @@ public function test_reset_default_labels_clears_cache() { $labels = WP_Comment_Type::get_default_labels(); $this->assertSame( 'Comments', $labels['name'][0] ); } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_default_capability_type_and_cap_object() { + $comment_type = new WP_Comment_Type( 'foo' ); + + $this->assertSame( 'comment', $comment_type->capability_type ); + $this->assertIsObject( $comment_type->cap ); + $this->assertSame( 'edit_comment', $comment_type->cap->edit_comment ); + $this->assertSame( 'moderate_comments', $comment_type->cap->moderate_comments ); + } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_custom_capability_type_builds_cap_object() { + $comment_type = new WP_Comment_Type( 'foo', array( 'capability_type' => 'review' ) ); + + $this->assertSame( 'review', $comment_type->capability_type ); + $this->assertSame( 'edit_review', $comment_type->cap->edit_comment ); + $this->assertSame( 'edit_reviews', $comment_type->cap->edit_comments ); + $this->assertSame( 'moderate_reviews', $comment_type->cap->moderate_comments ); + } + + /** + * An array capability type allows an explicit plural and is collapsed to its singular base. + * + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_array_capability_type_uses_explicit_plural() { + $comment_type = new WP_Comment_Type( 'foo', array( 'capability_type' => array( 'story', 'stories' ) ) ); + + $this->assertSame( 'story', $comment_type->capability_type ); + $this->assertSame( 'edit_story', $comment_type->cap->edit_comment ); + $this->assertSame( 'edit_stories', $comment_type->cap->edit_comments ); + } + + /** + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_capabilities_argument_overrides_generated_caps() { + $comment_type = new WP_Comment_Type( + 'foo', + array( + 'capability_type' => 'review', + 'capabilities' => array( + 'moderate_comments' => 'manage_reviews', + ), + ) + ); + + $this->assertSame( 'manage_reviews', $comment_type->cap->moderate_comments ); + // Non-overridden caps are still generated from the capability type. + $this->assertSame( 'edit_reviews', $comment_type->cap->edit_comments ); + } + + /** + * The input `capabilities` array is consumed and not kept as a public property. + * + * @ticket 35214 + * + * @covers ::set_props + */ + public function test_capabilities_input_is_not_retained_as_property() { + $comment_type = new WP_Comment_Type( 'foo', array( 'capabilities' => array( 'edit_comments' => 'x' ) ) ); + + $this->assertObjectNotHasProperty( 'capabilities', $comment_type ); + } } 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'],