Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 95 additions & 6 deletions src/wp-includes/capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<code>' . $cap . '</code>' ),
'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, '<code>' . $cap . '</code>' ),
'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':
Expand Down
185 changes: 185 additions & 0 deletions tests/phpunit/tests/comment/commentCapabilities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<?php

/**
* Tests for capability enforcement of registered comment types via map_meta_cap().
*
* @group comment
* @group capabilities
*
* @covers ::map_meta_cap
*/
class Tests_Comment_CommentCapabilities extends WP_UnitTestCase {

/**
* Post the comments are attached to.
*
* @var int
*/
protected static $post_id;

/**
* Administrator user ID (has moderate_comments and can edit the post).
*
* @var int
*/
protected static $admin_id;

/**
* Subscriber user ID (no comment capabilities).
*
* @var int
*/
protected static $subscriber_id;

public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
self::$admin_id = $factory->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 ) );
}
}
2 changes: 2 additions & 0 deletions tests/phpunit/tests/user/capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading