From c4ba247ac7c510a01a176a2908ac7ba04ce18c1a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 15:28:25 -0700 Subject: [PATCH 1/5] Comments: Add a capabilities object to comment types. Give `WP_Comment_Type` a `cap` object built from new `capability_type` and `capabilities` registration arguments, modeled on `WP_Post_Type` and `get_post_type_capabilities()`. A new `get_comment_type_capabilities()` helper builds the capability strings from the `capability_type` base (default 'comment'), so a registered type can describe its own read, edit, delete, and moderate capabilities. This is advisory metadata only: `map_meta_cap()` is intentionally not changed, so there is no behavior change. The built-in `comment` type resolves to the existing `edit_comment` and `moderate_comments` capabilities, preserving current behavior. Enforcing per-type capabilities through `map_meta_cap()` is left to a follow-up so the capability model can be agreed on first. See #35214. --- src/wp-includes/class-wp-comment-type.php | 49 ++++++++++++++++--- src/wp-includes/comment.php | 59 ++++++++++++++++++++++- 2 files changed, 100 insertions(+), 8 deletions(-) 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. * From f9e7b7694aaf0a6eb34f2faa38e9c377b7416bd9 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 15:28:25 -0700 Subject: [PATCH 2/5] Comments: Add tests for comment type capabilities. Cover the new `capability_type`/`capabilities` arguments and the `get_comment_type_capabilities()` helper: default and custom capability types, array capability types with explicit plurals, the `capabilities` override, that the input `capabilities` array is not retained as a property, and that the built-in `comment` type stays backward compatible with the existing core capabilities. See #35214. --- tests/phpunit/tests/comment/types.php | 62 +++++++++++++++ tests/phpunit/tests/comment/wpCommentType.php | 77 +++++++++++++++++++ 2 files changed, 139 insertions(+) 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 ); + } } From c12fd0d1830a0bd0a5255560409f199cbec9cadb Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 20:25:23 -0700 Subject: [PATCH 3/5] Comments: Enforce registered comment type capabilities in `map_meta_cap()`. The capabilities object added for comment types was advisory only; `map_meta_cap()` knew a single comment cap (`edit_comment`, derived from the parent post) and all moderation ran through a bare `moderate_comments` primitive check. Wire the cap object into `map_meta_cap()` so a registered type can enforce its own permissions, supporting both models: - Types using the default `comment` capability model are unchanged. Their mapped `edit_comment` is the generic meta cap, so editing and deletion still derive from the comment's parent post and moderation still requires the global `moderate_comments` primitive. - A type that opts into its own `capability_type`/`capabilities` is gated by its own primitives (e.g. `moderate_reviews`, `edit_others_reviews`), mirroring how registered post types map `edit_post`. Caps are mapped, not granted: sites assign the primitives to roles, so nothing is silently escalated or locked out. Generalize the `edit_comment` case and add `delete_comment` and `moderate_comment` meta caps. `read_comment` is deferred: the cap object has no read primitive and approved comments are publicly readable, so gating reads needs its own design. See #35214. --- src/wp-includes/capabilities.php | 101 +++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 6 deletions(-) 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': From 4212f20c85c98189b54c658bbf325f0c3dcd4bfd Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 20:25:23 -0700 Subject: [PATCH 4/5] Comments: Add tests for comment type capability enforcement. Cover both capability models in `map_meta_cap()`: the default `comment` model still derives edit/delete from the parent post (including the orphaned comment fallback) and moderation from `moderate_comments`, while a type with an independent `capability_type` is gated by its own primitives with no fallback to the default caps. Register `delete_comment` and `moderate_comment` in the meta-capability coverage list. See #35214. --- .../tests/comment/commentCapabilities.php | 185 ++++++++++++++++++ tests/phpunit/tests/user/capabilities.php | 2 + 2 files changed, 187 insertions(+) create mode 100644 tests/phpunit/tests/comment/commentCapabilities.php 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'], From cdabc800c8bedeab2f3cfe2efc817b857c135c6d Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 10:42:26 -0700 Subject: [PATCH 5/5] Comments: Add custom meta capability translation for comment types. current_user_can( 'edit_review', $comment_id ) - the idiom developers know from custom post types, and the exact names the capability object advertises - previously fell through map_meta_cap()'s default case to a literal primitive check that ignored the comment ID and the own/others split. Mirror _post_type_meta_capabilities(): remember each type's custom edit_comment/delete_comment/moderate_comment names and translate them in the default case. read_comment is deliberately excluded until a corresponding case exists. - Guard against claiming names already registered as post type meta capabilities (those take precedence); reject with _doing_it_wrong(). - Remove a type's translations in unregister_comment_type(), mirroring the post type cleanup, and reset the registry between tests. - Consolidate the three map_meta_cap() comment cases into one fall-through block with a single, documented model-detection point (overriding a singular meta capability opts that action into the independent model), and note that the independent model deliberately ignores the parent post. - Rewrite the 'advisory only' capability docblocks from the caps PR to describe the live model, including the mixed-model and empty capability_type footguns, and add map_meta_cap()'s @since entry. - Tests: legacy empty-string types across all three meta caps, orphaned independent comments, invalid and missing IDs, anonymous own/others, array capability_type end to end, single-override model flips, register/unregister fallback, trashed parents, custom-name translation, and the post type collision guard. --- src/wp-includes/capabilities.php | 117 +++----- src/wp-includes/class-wp-comment-type.php | 10 +- src/wp-includes/comment.php | 86 +++++- tests/phpunit/includes/abstract-testcase.php | 4 + .../tests/comment/commentCapabilities.php | 278 +++++++++++++++++- 5 files changed, 398 insertions(+), 97 deletions(-) diff --git a/src/wp-includes/capabilities.php b/src/wp-includes/capabilities.php index 1fc9a4b8bda93..3980dd4ded488 100644 --- a/src/wp-includes/capabilities.php +++ b/src/wp-includes/capabilities.php @@ -34,8 +34,12 @@ * `edit_app_password`, `delete_app_passwords`, `delete_app_password`, * and `update_https` capabilities. * @since 6.7.0 Added the `edit_block_binding` capability. + * @since 7.1.0 Added the `delete_comment` and `moderate_comment` meta capabilities, + * support for registered comment type capabilities in `edit_comment`, + * and translation of custom comment type meta capabilities. * - * @global array $post_type_meta_caps Used to get post type meta capabilities. + * @global array $post_type_meta_caps Used to get post type meta capabilities. + * @global array $comment_type_meta_caps Used to get comment type meta capabilities. * * @param string $cap Capability being checked. * @param int $user_id User ID. @@ -552,6 +556,8 @@ function map_meta_cap( $cap, $user_id, ...$args ) { } break; case 'edit_comment': + case 'delete_comment': + 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.' ); @@ -559,7 +565,7 @@ function map_meta_cap( $cap, $user_id, ...$args ) { _doing_it_wrong( __FUNCTION__, sprintf( $message, '' . $cap . '' ), - '6.1.0' + 'edit_comment' === $cap ? '6.1.0' : '7.1.0' ); $caps[] = 'do_not_allow'; @@ -574,14 +580,29 @@ function map_meta_cap( $cap, $user_id, ...$args ) { $comment_type = get_comment_type_object( $comment->comment_type ); + /* + * Moderation is gated by the type's moderation primitive, which for + * unregistered and default-model types is the global `moderate_comments` + * primitive, exactly as before. + */ + if ( 'moderate_comment' === $cap ) { + $caps[] = $comment_type ? $comment_type->cap->moderate_comments : 'moderate_comments'; + break; + } + /* * 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`. + * and delete permission from the comment's parent post, preserving + * historical behavior. Overriding a singular meta capability (via + * 'capability_type' or 'capabilities') opts that action into the + * independent model: the type's own plural primitives gate it instead, + * mirroring how registered post types map `edit_post`. The independent + * model deliberately does not consult the parent post, so a trashed or + * private parent does not affect who can act on such comments. */ - if ( ! $comment_type || 'edit_comment' === $comment_type->cap->edit_comment ) { + $uses_default_model = ! $comment_type || $cap === $comment_type->cap->$cap; + + if ( $uses_default_model ) { $post = get_post( $comment->comment_post_ID ); /* @@ -593,86 +614,14 @@ function map_meta_cap( $cap, $user_id, ...$args ) { } else { $caps = map_meta_cap( 'edit_posts', $user_id ); } + } elseif ( 'delete_comment' === $cap ) { + $caps[] = $comment_type->cap->delete_comments; } 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[] = 'moderate_comments'; - } - break; case 'unfiltered_upload': if ( defined( 'ALLOW_UNFILTERED_UPLOADS' ) && ALLOW_UNFILTERED_UPLOADS && ( ! is_multisite() || is_super_admin( $user_id ) ) ) { $caps[] = $cap; @@ -932,6 +881,12 @@ function map_meta_cap( $cap, $user_id, ...$args ) { return map_meta_cap( $post_type_meta_caps[ $cap ], $user_id, ...$args ); } + // Handle meta capabilities for custom comment types. + global $comment_type_meta_caps; + if ( isset( $comment_type_meta_caps[ $cap ] ) ) { + return map_meta_cap( $comment_type_meta_caps[ $cap ], $user_id, ...$args ); + } + // Block capabilities map to their post equivalent. $block_caps = array( 'edit_blocks', diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 6bc4f6c606976..ccac539ec88d9 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -107,10 +107,12 @@ final class WP_Comment_Type { * 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. + * `capability_type` and `capabilities` arguments. Types using the default + * capability model derive edit and delete permission from the comment's + * parent post and moderation from the global `moderate_comments` + * capability. A type that overrides a singular meta capability is instead + * gated by its own plural primitive capabilities in {@see map_meta_cap()}, + * which the plugin registering the type must grant to roles. * * @since 7.1.0 * @var stdClass diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 132e20e2585b6..d192e804fc86d 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -469,13 +469,14 @@ function register_comment_type( $comment_type, $args = array() ) { * * @since 7.1.0 * - * @global WP_Comment_Type[] $wp_comment_types List of comment types. + * @global WP_Comment_Type[] $wp_comment_types List of comment types. + * @global array $comment_type_meta_caps Used to remove meta capabilities. * * @param string $comment_type Comment type key. * @return true|WP_Error True on success, WP_Error on failure or if the comment type doesn't exist. */ function unregister_comment_type( $comment_type ) { - global $wp_comment_types; + global $wp_comment_types, $comment_type_meta_caps; if ( ! comment_type_exists( $comment_type ) ) { return new WP_Error( 'invalid_comment_type', __( 'Invalid comment type.' ) ); @@ -488,6 +489,11 @@ function unregister_comment_type( $comment_type ) { return new WP_Error( 'invalid_comment_type', __( 'Unregistering a built-in comment type is not allowed.' ) ); } + // Remove custom meta capabilities registered for this comment type. + foreach ( (array) $comment_type_object->cap as $custom_cap ) { + unset( $comment_type_meta_caps[ $custom_cap ] ); + } + unset( $wp_comment_types[ $comment_type ] ); /** @@ -629,15 +635,31 @@ function get_comment_type_labels( $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. + * This is modeled on {@see get_post_type_capabilities()}. By default the + * capability model is the historical one: edit and delete permission for a + * comment derives from the comment's parent post, and moderation requires the + * global `moderate_comments` capability. Overriding a singular meta capability - + * for example registering with `capability_type => 'review'`, or passing a + * custom `edit_comment` name in `capabilities` - opts that action into the + * independent model: {@see map_meta_cap()} then requires the type's + * corresponding plural primitive capabilities (e.g. `edit_reviews`, + * `edit_others_reviews`), which exist in no role by default. As with custom + * post type capabilities, the plugin registering the type must grant those + * primitives to roles, typically on activation. * * 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' ). * + * Warning: The two models can mix per action. Registering with the default + * `capability_type` of 'comment' but a `capabilities` array of + * `array( 'edit_comment' => 'edit_my_comment' )` flips only editing to the + * independent model, gated by the generated `edit_comments` primitive - which + * no default role has. Similarly, an empty `capability_type` produces + * capabilities such as `edit_` and `edit_s` that lock everyone out; the value + * is not validated, matching register_post_type(). + * * Note: With the default `capability_type` of 'comment', most of the generated * primitive capabilities (`edit_comments`, `edit_others_comments`, * `delete_comments`) exist in no default role and are not consulted by the @@ -698,7 +720,59 @@ function get_comment_type_capabilities( $args ) { $capabilities = array_merge( $default_capabilities, $args->capabilities ); - return (object) $capabilities; + $capabilities = (object) $capabilities; + + // Remember custom meta capabilities so map_meta_cap() can translate them. + _comment_type_meta_capabilities( $capabilities ); + + return $capabilities; +} + +/** + * Stores a list of comment type meta capabilities for map_meta_cap(). + * + * Only the singular meta capabilities that map_meta_cap() can translate are + * remembered: `edit_comment`, `delete_comment`, and `moderate_comment`. + * `read_comment` is excluded until a corresponding case exists in + * map_meta_cap(). Identity mappings (a custom name equal to the generic name) + * are skipped, as are names already registered as post type meta capabilities, + * which take precedence in map_meta_cap(). + * + * @since 7.1.0 + * @access private + * + * @global array $comment_type_meta_caps Used to store comment type meta capabilities. + * @global array $post_type_meta_caps Post type meta capabilities, checked for collisions. + * + * @param object $capabilities Comment type capabilities object. + */ +function _comment_type_meta_capabilities( $capabilities ) { + global $comment_type_meta_caps, $post_type_meta_caps; + + foreach ( (array) $capabilities as $core => $custom ) { + if ( ! in_array( $core, array( 'edit_comment', 'delete_comment', 'moderate_comment' ), true ) ) { + continue; + } + + if ( $core === $custom ) { + continue; + } + + if ( isset( $post_type_meta_caps[ $custom ] ) ) { + _doing_it_wrong( + 'register_comment_type', + sprintf( + /* translators: %s: Capability name. */ + __( 'The meta capability "%s" is already registered for a post type and cannot be reused for a comment type.' ), + $custom + ), + '7.1.0' + ); + continue; + } + + $comment_type_meta_caps[ $custom ] = $core; + } } /** diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php index 3cab3b3c042c5..fc00cc4d542b2 100644 --- a/tests/phpunit/includes/abstract-testcase.php +++ b/tests/phpunit/includes/abstract-testcase.php @@ -359,6 +359,10 @@ protected function reset_taxonomies() { * it has a chance to do so. */ protected function reset_comment_types() { + // Direct get_comment_type_capabilities() calls register translations + // without a comment type to unregister, so reset the registry wholesale. + $GLOBALS['comment_type_meta_caps'] = array(); + foreach ( get_comment_types( array( '_builtin' => false ) ) as $comment_type ) { _unregister_comment_type( $comment_type ); } diff --git a/tests/phpunit/tests/comment/commentCapabilities.php b/tests/phpunit/tests/comment/commentCapabilities.php index 001813edf969b..9c8a77ff7da50 100644 --- a/tests/phpunit/tests/comment/commentCapabilities.php +++ b/tests/phpunit/tests/comment/commentCapabilities.php @@ -44,12 +44,6 @@ public function set_up() { 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. * @@ -182,4 +176,276 @@ public function test_independent_type_delete_requires_its_own_primitive() { $this->assertTrue( user_can( $deleter->ID, 'delete_comment', $review_id ) ); $this->assertFalse( user_can( self::$subscriber_id, 'delete_comment', $review_id ) ); } + + /** + * A comment stored with the legacy empty string type uses the default model + * for all three meta capabilities (the null type object fallback). + * + * @ticket 35214 + */ + public function test_legacy_empty_type_uses_default_model_for_all_meta_caps() { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_type' => '', + ) + ); + + foreach ( array( 'edit_comment', 'delete_comment', 'moderate_comment' ) as $meta_cap ) { + $this->assertTrue( user_can( self::$admin_id, $meta_cap, $comment_id ), "Admin should have {$meta_cap} on a legacy comment." ); + $this->assertFalse( user_can( self::$subscriber_id, $meta_cap, $comment_id ), "Subscriber should not have {$meta_cap} on a legacy comment." ); + } + } + + /** + * The independent model never consults the parent post: an orphaned comment + * of an independent type is fully controlled by the type's primitives. + * + * @ticket 35214 + */ + public function test_independent_type_ignores_parent_post_entirely() { + $orphan_review = self::factory()->comment->create( + array( + 'comment_post_ID' => 0, + 'comment_type' => 'review', + ) + ); + + $power_user = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $power_user->add_cap( 'edit_others_reviews' ); + $power_user->add_cap( 'delete_reviews' ); + $power_user->add_cap( 'moderate_reviews' ); + + $this->assertTrue( user_can( $power_user->ID, 'edit_comment', $orphan_review ) ); + $this->assertTrue( user_can( $power_user->ID, 'delete_comment', $orphan_review ) ); + $this->assertTrue( user_can( $power_user->ID, 'moderate_comment', $orphan_review ) ); + + // The edit_posts fallback for orphaned default comments does not apply. + $this->assertFalse( user_can( self::$admin_id, 'edit_comment', $orphan_review ) ); + } + + /** + * An invalid comment ID maps to do_not_allow for all three meta capabilities. + * + * @ticket 35214 + */ + public function test_invalid_comment_id_is_denied_for_all_meta_caps() { + $invalid_id = PHP_INT_MAX; + + foreach ( array( 'edit_comment', 'delete_comment', 'moderate_comment' ) as $meta_cap ) { + $this->assertSame( array( 'do_not_allow' ), map_meta_cap( $meta_cap, self::$admin_id, $invalid_id ), "{$meta_cap} should map to do_not_allow." ); + $this->assertFalse( user_can( self::$admin_id, $meta_cap, $invalid_id ), "Admin should not have {$meta_cap} for an invalid comment." ); + } + + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + $this->assertFalse( user_can( self::$admin_id, 'edit_comment', $invalid_id ), 'do_not_allow should deny even super admins.' ); + revoke_super_admin( self::$admin_id ); + } + } + + /** + * @ticket 35214 + * + * @expectedIncorrectUsage map_meta_cap + */ + public function test_delete_comment_without_comment_id_is_denied() { + $this->assertFalse( user_can( self::$admin_id, 'delete_comment' ) ); + } + + /** + * An anonymous comment (user_id 0) never matches the "own comment" branch + * of the independent model. + * + * @ticket 35214 + */ + public function test_anonymous_independent_comment_never_matches_own() { + $anon_review = $this->make_comment( 'review', 0 ); + + $author = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $author->add_cap( 'edit_reviews' ); + + $this->assertFalse( user_can( $author->ID, 'edit_comment', $anon_review ), 'edit_reviews alone should not grant editing an anonymous 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', $anon_review ), 'edit_others_reviews should grant editing an anonymous comment.' ); + } + + /** + * An array capability_type supplies the plural base enforced by map_meta_cap(). + * + * @ticket 35214 + */ + public function test_array_capability_type_is_enforced_end_to_end() { + register_comment_type( 'story_note', array( 'capability_type' => array( 'story', 'stories' ) ) ); + + $story_id = $this->make_comment( 'story_note', self::$admin_id ); + + $editor = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $editor->add_cap( 'edit_others_stories' ); + + $this->assertTrue( user_can( $editor->ID, 'edit_comment', $story_id ) ); + $this->assertFalse( user_can( self::$subscriber_id, 'edit_comment', $story_id ) ); + + $moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $moderator->add_cap( 'moderate_stories' ); + + $this->assertTrue( user_can( $moderator->ID, 'moderate_comment', $story_id ) ); + $this->assertFalse( user_can( self::$admin_id, 'moderate_comment', $story_id ) ); + } + + /** + * Overriding a single meta capability flips only that action to the + * independent model; the other actions keep the default model. + * + * This also pins the documented footgun: with the default capability_type, + * the flipped action is gated by generated primitives no default role has. + * + * @ticket 35214 + */ + public function test_single_capability_override_flips_only_that_action() { + register_comment_type( + 'annotation', + array( + 'capabilities' => array( 'edit_comment' => 'edit_annotation' ), + ) + ); + + $annotation_id = $this->make_comment( 'annotation' ); + + // Editing is now independent, gated by 'edit_others_comments' - which even admins lack. + $this->assertFalse( user_can( self::$admin_id, 'edit_comment', $annotation_id ) ); + + // Deletion and moderation stay on the default model. + $this->assertTrue( user_can( self::$admin_id, 'delete_comment', $annotation_id ) ); + $this->assertTrue( user_can( self::$admin_id, 'moderate_comment', $annotation_id ) ); + } + + /** + * Unregistering a type reverts its stored comments to the default model + * (the plugin-deactivation scenario). + * + * @ticket 35214 + */ + public function test_unregistered_type_falls_back_to_default_model() { + $review_id = $this->make_comment( 'review' ); + + $moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $moderator->add_cap( 'moderate_reviews' ); + + $this->assertTrue( user_can( $moderator->ID, 'moderate_comment', $review_id ) ); + $this->assertFalse( user_can( self::$admin_id, 'moderate_comment', $review_id ) ); + + unregister_comment_type( 'review' ); + + $this->assertFalse( user_can( $moderator->ID, 'moderate_comment', $review_id ) ); + $this->assertTrue( user_can( self::$admin_id, 'moderate_comment', $review_id ) ); + $this->assertTrue( user_can( self::$admin_id, 'edit_comment', $review_id ) ); + } + + /** + * The default model still consults a trashed parent post normally. + * + * @ticket 35214 + */ + public function test_default_model_still_consults_trashed_parent_post() { + $post_id = self::factory()->post->create( array( 'post_author' => self::$admin_id ) ); + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'comment', + ) + ); + + wp_trash_post( $post_id ); + + $this->assertTrue( user_can( self::$admin_id, 'edit_comment', $comment_id ) ); + $this->assertFalse( user_can( self::$subscriber_id, 'edit_comment', $comment_id ) ); + } + + /* + * Custom meta capability names: current_user_can( 'edit_review', $comment_id ) + * translates to the generic meta capability, mirroring post type meta caps. + */ + + /** + * @ticket 35214 + */ + public function test_custom_meta_cap_name_translates_via_map_meta_cap() { + $review_id = $this->make_comment( 'review' ); + + $moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $moderator->add_cap( 'moderate_reviews' ); + + $this->assertTrue( user_can( $moderator->ID, 'moderate_review', $review_id ) ); + $this->assertFalse( user_can( self::$subscriber_id, 'moderate_review', $review_id ) ); + + $this->assertSame( + map_meta_cap( 'moderate_comment', $moderator->ID, $review_id ), + map_meta_cap( 'moderate_review', $moderator->ID, $review_id ), + 'The custom name should map exactly like the generic name.' + ); + } + + /** + * @ticket 35214 + */ + public function test_custom_edit_meta_cap_name_respects_own_others_split() { + $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 ); + + $this->assertTrue( user_can( $author->ID, 'edit_review', $own_review ) ); + $this->assertFalse( user_can( $author->ID, 'edit_review', $others_review ) ); + } + + /** + * Unregistering a type removes its custom meta capability translations. + * + * @ticket 35214 + */ + public function test_unregistering_type_removes_custom_meta_cap_translation() { + $review_id = $this->make_comment( 'review' ); + + $moderator = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $moderator->add_cap( 'moderate_reviews' ); + + $this->assertTrue( user_can( $moderator->ID, 'moderate_review', $review_id ) ); + + unregister_comment_type( 'review' ); + + $this->assertFalse( user_can( $moderator->ID, 'moderate_review', $review_id ), 'The translation should be removed with the type.' ); + } + + /** + * A comment type cannot claim a meta capability name already registered + * for a post type; the post type translation is preserved. + * + * @ticket 35214 + * + * @expectedIncorrectUsage register_comment_type + */ + public function test_comment_type_cannot_claim_post_type_meta_cap_names() { + global $post_type_meta_caps, $comment_type_meta_caps; + + register_post_type( + 'book', + array( + 'capability_type' => 'book', + 'map_meta_cap' => true, + ) + ); + register_comment_type( 'book_note', array( 'capability_type' => 'book' ) ); + + $this->assertSame( 'edit_post', $post_type_meta_caps['edit_book'], 'The post type translation should be preserved.' ); + $this->assertArrayNotHasKey( 'edit_book', (array) $comment_type_meta_caps ); + $this->assertArrayNotHasKey( 'delete_book', (array) $comment_type_meta_caps ); + + // The non-colliding moderation capability is still registered. + $this->assertSame( 'moderate_comment', $comment_type_meta_caps['moderate_book'] ); + } }