diff --git a/src/wp-includes/class-wp-comment-type.php b/src/wp-includes/class-wp-comment-type.php index 64566d5ef35d1..a4f04ddd554d7 100644 --- a/src/wp-includes/class-wp-comment-type.php +++ b/src/wp-includes/class-wp-comment-type.php @@ -89,6 +89,33 @@ final class WP_Comment_Type { */ public $internal = false; + /** + * The string to use to build the edit, delete, and moderate capabilities. + * + * May be registered as an array to allow for alternative plurals when using + * this argument as a base to construct the capabilities, e.g. + * array( 'story', 'stories' ). set_props() collapses the array form back to + * the singular base once the capabilities are built. Default 'comment'. + * + * @since 7.1.0 + * @var string + */ + 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. * @@ -183,17 +210,28 @@ 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, - '_builtin' => false, + 'labels' => array(), + 'description' => '', + 'public' => true, + 'internal' => false, + 'capability_type' => 'comment', + 'capabilities' => array(), + '_builtin' => false, ); $args = array_merge( $defaults, $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]; + } + /* * Comment types are never hierarchical. The property exists only so the shared * label helper can pick a slot, and the hierarchical slot is deliberately null, diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index d8bbc0158922d..0b9282c919461 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -434,22 +434,39 @@ function create_initial_comment_types() { * @param array|string $args { * Optional. Array or string of arguments for registering a comment type. Default empty array. * - * @type string $label Name of the comment type. Usually plural. - * Default is the value of $labels['name']. - * @type string[] $labels An array of labels for this comment type. If not set, the - * default comment labels are used. See get_comment_type_labels() - * for a full list of supported labels. - * @type string $description A short descriptive summary of what the comment type is. - * Default empty. - * @type bool $public Whether the comment type is intended for use publicly either via - * the admin interface or by front-end users. Core does not - * currently act on this argument. Default true. - * @type bool $internal Whether the comment type is for internal use only. Internal types - * are excluded from comment queries and counts by default, through - * the {@see 'default_excluded_comment_types'} filter. Default false. - * @type bool $_builtin For internal core use only. Marks the type as native to - * WordPress, which blocks it from being re-registered or - * unregistered. Default false. + * @type string $label Name of the comment type. Usually plural. + * Default is the value of $labels['name']. + * @type string[] $labels An array of labels for this comment type. If not set, the + * default comment labels are used. See + * get_comment_type_labels() for a full list of supported + * labels. + * @type string $description A short descriptive summary of what the comment type is. + * Default empty. + * @type bool $public Whether the comment type is intended for use publicly + * either via the admin interface or by front-end users. + * Core does not currently act on this argument. + * Default true. + * @type bool $internal Whether the comment type is for internal use only. + * Internal types are excluded from comment queries and + * counts by default, through the + * {@see 'default_excluded_comment_types'} filter. + * Default false. + * @type string|array $capability_type The string to use to build the edit, delete, and moderate + * 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' ). + * These capabilities are advisory metadata and are not + * enforced by core's capability mapping in this release. + * Default 'comment'. + * @type string[] $capabilities Array of capabilities for this comment type. + * $capability_type is used as a base to construct + * capabilities by default. As with $capability_type, these + * are advisory metadata and are not enforced by core's + * capability mapping in this release. + * See get_comment_type_capabilities(). + * @type bool $_builtin For internal core use only. Marks the type as native to + * WordPress, which blocks it from being re-registered or + * unregistered. Default false. * } * @return WP_Comment_Type|WP_Error The registered comment type object on success, * WP_Error object on failure. @@ -705,6 +722,92 @@ 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' ). + * + * 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 + * default capability mapping; `moderate_comments` is the only generated + * primitive that default roles grant. Consumers should check the meta + * capabilities together with a comment ID instead of testing those primitives + * directly. + * + * Note: In this release, `edit_comment` on the default 'comment' base is the + * only generated capability that map_meta_cap() resolves. The remaining meta + * capabilities, and every capability generated from a custom base, are treated + * as primitives: a check requires the literal capability, which no default role + * grants, so it denies everyone until comment type capability mapping is added. + * + * Note: Do not reuse a post type's `capability_type` as a comment type base. + * A base of 'post' generates `edit_comment => 'edit_post'`, which map_meta_cap() + * resolves through its post branch, so the check would be answered by a post + * with the passed comment's ID. + * + * Note: The `capability_type` property of the passed object is normalized to + * its array form as a side effect of calling this function, matching + * get_post_type_capabilities(). + * + * @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. + * + * @type string $edit_comment Meta capability to edit a comment of this type. + * Default 'edit_comment'. + * @type string $delete_comment Meta capability to delete a comment of this type. + * Default 'delete_comment'. + * @type string $moderate_comment Meta capability to moderate a comment of this type. + * Default 'moderate_comment'. + * @type string $edit_comments Primitive capability to edit comments of this type. + * Default 'edit_comments'. + * @type string $edit_others_comments Primitive capability to edit comments of this type + * authored by other users. Default 'edit_others_comments'. + * @type string $delete_comments Primitive capability to delete comments of this type. + * Default 'delete_comments'. + * @type string $moderate_comments Primitive capability to moderate comments of this type. + * Default 'moderate_comments'. + * } + */ +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, + '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/types.php b/tests/phpunit/tests/comment/types.php index 79774eb440b18..895b6ad2b88fc 100644 --- a/tests/phpunit/tests/comment/types.php +++ b/tests/phpunit/tests/comment/types.php @@ -523,6 +523,182 @@ public function test_registered_comment_type_action_receives_type_and_object() { $this->assertSame( 'foo', $args[0][1]->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 ); + } + + /** + * The full set of meta and primitive capabilities is generated from the base. + * + * @ticket 35214 + * + * @covers ::get_comment_type_capabilities + */ + public function test_get_comment_type_capabilities_generates_full_set() { + $caps = get_comment_type_capabilities( + (object) array( + 'capability_type' => 'review', + 'capabilities' => array(), + ) + ); + + $expected = array( + // Meta capabilities. + 'edit_comment' => 'edit_review', + 'delete_comment' => 'delete_review', + 'moderate_comment' => 'moderate_review', + // Primitive capabilities. + 'edit_comments' => 'edit_reviews', + 'edit_others_comments' => 'edit_others_reviews', + 'delete_comments' => 'delete_reviews', + 'moderate_comments' => 'moderate_reviews', + ); + + $this->assertSame( $expected, (array) $caps ); + } + + /** + * An array capability type supplies an explicit plural base for primitive caps. + * + * @ticket 35214 + * + * @covers ::get_comment_type_capabilities + */ + public function test_get_comment_type_capabilities_from_array() { + $caps = get_comment_type_capabilities( + (object) array( + 'capability_type' => array( 'story', 'stories' ), + 'capabilities' => array(), + ) + ); + + // Singular base drives the meta capabilities. + $this->assertSame( 'edit_story', $caps->edit_comment ); + $this->assertSame( 'delete_story', $caps->delete_comment ); + // Explicit plural base drives the primitive capabilities. + $this->assertSame( 'edit_stories', $caps->edit_comments ); + $this->assertSame( 'delete_stories', $caps->delete_comments ); + $this->assertSame( 'moderate_stories', $caps->moderate_comments ); + } + + /** + * A 'read_comment' meta capability is deliberately not generated: map_meta_cap() has no + * case for it, so advertising it would hand consumers a capability that denies everyone. + * Pinned so it is only added alongside its mapping. + * + * @ticket 35214 + * + * @covers ::get_comment_type_capabilities + */ + public function test_get_comment_type_capabilities_omits_read_comment() { + $caps = get_comment_type_capabilities( + (object) array( + 'capability_type' => 'review', + 'capabilities' => array(), + ) + ); + + $this->assertObjectNotHasProperty( 'read_comment', $caps ); + $this->assertObjectNotHasProperty( 'read_comment', get_comment_type_object( 'comment' )->cap ); + } + + /** + * The built-in types all register without a capability type, so they share one cap set. + * Anything that gated on the existing comment capabilities keeps working for all four. + * + * @ticket 35214 + * + * @dataProvider data_built_in_comment_types + * + * @param string $comment_type Built-in comment type name. + */ + public function test_built_in_comment_types_share_the_comment_capabilities( $comment_type ) { + $this->assertEquals( + get_comment_type_object( 'comment' )->cap, + get_comment_type_object( $comment_type )->cap + ); + } + + /** + * An override applies to meta capabilities as well as primitives, so a type can point a + * meta capability at a name that map_meta_cap() already resolves. + * + * @ticket 35214 + * + * @covers ::get_comment_type_capabilities + */ + public function test_get_comment_type_capabilities_honors_a_meta_capability_override() { + $caps = get_comment_type_capabilities( + (object) array( + 'capability_type' => 'review', + 'capabilities' => array( + 'edit_comment' => 'edit_review_item', + ), + ) + ); + + $this->assertSame( 'edit_review_item', $caps->edit_comment, 'The override should win.' ); + $this->assertSame( 'delete_review', $caps->delete_comment, 'The rest should still derive from the base.' ); + } + /** * Comment types are never hierarchical. The default labels reserve the hierarchical * slot as null, so honoring a provided value would resolve every label to null. diff --git a/tests/phpunit/tests/comment/wpCommentType.php b/tests/phpunit/tests/comment/wpCommentType.php index dc316aac4a735..4e995137f02ab 100644 --- a/tests/phpunit/tests/comment/wpCommentType.php +++ b/tests/phpunit/tests/comment/wpCommentType.php @@ -121,4 +121,81 @@ public function test_reset_default_labels_clears_cache() { $labels = WP_Comment_Type::get_default_labels(); $this->assertSame( 'Comments', $labels['name'][0], 'Resetting should rebuild the default labels.' ); } + + /** + * @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 ); + } }