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
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,7 @@ public function create_item_permissions_check( $request ) {
*
* @since 4.7.0
* @since 7.2.0 Added support for the `reaction` comment type.
* @since 7.2.0 A note reply to a deleted note is rejected.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or error object on failure.
Expand Down Expand Up @@ -879,6 +880,22 @@ public function create_item( $request ) {
$reaction_key = $emoji_key;
}

/*
* Another session can delete a note while a reply to it is being
* written. Stored under the deleted note, the reply would be lost:
* the editor no longer shows its thread. See also update_item().
*/
if ( ! empty( $request['type'] ) && 'note' === $request['type'] && ! empty( $request['parent'] ) ) {
$parent_note = get_comment( $request['parent'] );
if ( ! $parent_note || in_array( $parent_note->comment_approved, array( 'trash', 'spam' ), true ) ) {
return new WP_Error(
'rest_comment_invalid_parent',
__( 'This note was deleted, so your reply could not be added.' ),
array( 'status' => 400 )
);
}
}

$prepared_comment = $this->prepare_item_for_database( $request );
if ( is_wp_error( $prepared_comment ) ) {
return $prepared_comment;
Expand Down Expand Up @@ -1209,6 +1226,7 @@ public function update_item_permissions_check( $request ) {
* Updates a comment.
*
* @since 4.7.0
* @since 7.2.0 A deleted note can only be restored, not otherwise changed.
*
* @param WP_REST_Request $request Full details about the request.
* @return WP_REST_Response|WP_Error Response object on success, or error object on failure.
Expand All @@ -1229,6 +1247,24 @@ public function update_item( $request ) {
);
}

/*
* Another session can delete a note while it is still open in the
* editor. Changing its status would bring it back without the replies
* trashed along with it, as reopening it with a reply does, and an
* edited note would stay hidden. Only allow restoring it.
*/
if (
'note' === $comment->comment_type &&
in_array( $comment->comment_approved, array( 'trash', 'spam' ), true ) &&
! in_array( $request['status'], array( 'untrash', 'unspam' ), true )
) {
return new WP_Error(
'rest_comment_invalid_status',
__( 'This note was deleted, so it could not be updated.' ),
array( 'status' => 400 )
);
}

$prepared_args = $this->prepare_item_for_database( $request );

if ( is_wp_error( $prepared_args ) ) {
Expand Down
272 changes: 272 additions & 0 deletions tests/phpunit/tests/rest-api/rest-comments-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -6423,4 +6423,276 @@ public function test_reaction_summary_excludes_trashed_reactions() {

$this->assertSame( array(), $response->get_data()['reaction_summary'] );
}

/**
* Creates a note, as the editor does, with one reply.
*
* @param int $post_id Post ID.
* @return int[] The note ID and the reply ID.
*/
private function create_note_with_reply( $post_id ) {
$note_id = self::factory()->comment->create(
array(
'comment_post_ID' => $post_id,
'comment_type' => 'note',
'comment_approved' => 0,
'user_id' => self::$editor_id,
'comment_content' => 'Test note',
)
);
$reply_id = self::factory()->comment->create(
array(
'comment_post_ID' => $post_id,
'comment_type' => 'note',
'comment_approved' => 0,
'comment_parent' => $note_id,
'user_id' => self::$editor_id,
'comment_content' => 'Test reply',
)
);

return array( $note_id, $reply_id );
}

/**
* Moves a note to the trash or marks it as spam.
*
* Unlike wp_set_comment_status(), wp_trash_comment() also trashes the
* replies of a top-level note, as deleting it in the editor does.
*
* @param int $note_id Note ID.
* @param string $status Either 'trash' or 'spam'.
*/
private function delete_note( $note_id, $status ) {
if ( 'trash' === $status ) {
wp_trash_comment( $note_id );
} else {
wp_spam_comment( $note_id );
}
}

/**
* Dispatches a request to reply to a note.
*
* @param int $post_id Post ID.
* @param int $parent_id ID of the note to reply to.
* @return WP_REST_Response Response object.
*/
private function reply_to_note( $post_id, $parent_id ) {
$request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
$request->add_header( 'Content-Type', 'application/json' );
$request->set_body(
wp_json_encode(
array(
'post' => $post_id,
'parent' => $parent_id,
'content' => 'Reply written after the delete',
'status' => 'hold',
'type' => 'note',
)
)
);

return rest_get_server()->dispatch( $request );
}

/**
* Dispatches a request to update a note.
*
* @param int $note_id Note ID.
* @param array $params Fields to update.
* @return WP_REST_Response Response object.
*/
private function update_note( $note_id, $params ) {
$request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $note_id );
$request->add_header( 'Content-Type', 'application/json' );
$request->set_body( wp_json_encode( $params ) );

return rest_get_server()->dispatch( $request );
}

/**
* Returns the IDs of a note's replies, whatever their status.
*
* @param int $note_id Note ID.
* @return int[] Reply IDs.
*/
private function get_note_reply_ids( $note_id ) {
return get_comments(
array(
'parent' => $note_id,
'type' => 'note',
'status' => 'any',
'fields' => 'ids',
)
);
}

/**
* A reply stored under a deleted note would be lost: the editor no longer
* shows its thread.
*
* @ticket 66351
*
* @dataProvider data_hidden_note_statuses
*
* @param string $status Status to move the parent note to.
*/
public function test_create_note_reply_to_deleted_note( $status ) {
wp_set_current_user( self::$editor_id );

$post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) );
$note_id = $this->create_note_with_reply( $post_id )[0];
$this->delete_note( $note_id, $status );
$reply_ids = $this->get_note_reply_ids( $note_id );

$response = $this->reply_to_note( $post_id, $note_id );

$this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 );
$this->assertSame( $reply_ids, $this->get_note_reply_ids( $note_id ), 'No reply should be stored under the deleted note.' );
}

/**
* Without the trash, a deleted note is gone entirely.
*
* @ticket 66351
*/
public function test_create_note_reply_to_permanently_deleted_note() {
wp_set_current_user( self::$editor_id );

$post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) );
$note_id = $this->create_note_with_reply( $post_id )[0];
wp_delete_comment( $note_id, true );

$response = $this->reply_to_note( $post_id, $note_id );

$this->assertErrorResponse( 'rest_comment_invalid_parent', $response, 400 );
}

/**
* Replying to an open note, or reopening a resolved one with a reply, still
* works.
*
* @ticket 66351
*
* @dataProvider data_note_reply_parent_statuses
*
* @param string $status Status to move the parent note to.
*/
public function test_create_note_reply( $status ) {
wp_set_current_user( self::$editor_id );

$post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) );
$note_id = $this->create_note_with_reply( $post_id )[0];
wp_set_comment_status( $note_id, $status );

$response = $this->reply_to_note( $post_id, $note_id );

$this->assertSame( 201, $response->get_status() );
$this->assertSame( $note_id, $response->get_data()['parent'] );
}

/**
* Data provider for test_create_note_reply().
*
* @return array[]
*/
public function data_note_reply_parent_statuses() {
return array(
'open' => array( 'hold' ),
'resolved' => array( 'approve' ),
);
}

/**
* Changing a deleted note would bring it back without the replies trashed
* along with it, or edit a note no one can see.
*
* @ticket 66351
*
* @dataProvider data_deleted_note_updates
*
* @param string $status Status to move the note to.
* @param array $params Fields to update.
*/
public function test_update_deleted_note( $status, $params ) {
wp_set_current_user( self::$editor_id );

$post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) );
list( $note_id, $reply_id ) = $this->create_note_with_reply( $post_id );
$this->delete_note( $note_id, $status );

$response = $this->update_note( $note_id, $params );

$this->assertErrorResponse( 'rest_comment_invalid_status', $response, 400 );
$this->assertSame( $status, wp_get_comment_status( $note_id ), 'The note should stay deleted.' );
$this->assertSame( 'Test note', get_comment( $note_id )->comment_content, 'The note should be unchanged.' );
if ( 'trash' === $status ) {
$this->assertSame( 'trash', wp_get_comment_status( $reply_id ), 'The reply should stay in the trash with its note.' );
}
}

/**
* Data provider for test_update_deleted_note().
*
* @return array[]
*/
public function data_deleted_note_updates() {
$updates = array(
'reopen' => array( 'status' => 'hold' ),
'resolve' => array( 'status' => 'approve' ),
'edit' => array( 'content' => 'Edited note' ),
);

$data = array();
foreach ( array( 'trash', 'spam' ) as $status ) {
foreach ( $updates as $name => $params ) {
$data[ "$name a note in $status" ] = array( $status, $params );
}
}

return $data;
}

/**
* Restoring a deleted note, as Undo in the editor does, still works.
*
* @ticket 66351
*/
public function test_update_restores_deleted_note() {
wp_set_current_user( self::$editor_id );

$post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) );
$note_id = $this->create_note_with_reply( $post_id )[0];
wp_trash_comment( $note_id );

$response = $this->update_note( $note_id, array( 'status' => 'untrash' ) );

$this->assertSame( 200, $response->get_status() );
$this->assertSame( 'unapproved', wp_get_comment_status( $note_id ) );
}

/**
* Reopening a resolved note with a reply, after another session deleted
* the note, neither brings the note back nor stores the reply.
*
* @ticket 66351
*/
public function test_reopen_deleted_note_with_reply() {
wp_set_current_user( self::$editor_id );

$post_id = self::factory()->post->create( array( 'post_author' => self::$editor_id ) );
list( $note_id, $reply_id ) = $this->create_note_with_reply( $post_id );
wp_set_comment_status( $note_id, 'approve' );
wp_trash_comment( $note_id );

// The editor reopens the thread, then adds the reply.
$reopen = $this->update_note( $note_id, array( 'status' => 'hold' ) );
$reply = $this->reply_to_note( $post_id, $note_id );

$this->assertErrorResponse( 'rest_comment_invalid_status', $reopen, 400 );
$this->assertErrorResponse( 'rest_comment_invalid_parent', $reply, 400 );
$this->assertSame( 'trash', wp_get_comment_status( $note_id ) );
$this->assertSame( 'trash', wp_get_comment_status( $reply_id ) );
}
}
Loading