diff --git a/src/wp-includes/block-editor.php b/src/wp-includes/block-editor.php index f08c934864916..996f4ed886253 100644 --- a/src/wp-includes/block-editor.php +++ b/src/wp-includes/block-editor.php @@ -634,6 +634,23 @@ function get_block_editor_settings( array $custom_settings, $block_editor_contex $editor_settings['canUpdateBlockBindings'] = current_user_can( 'edit_block_binding', $block_editor_context ); + /* + * Advertise which note actions are locked so the editor can hide affordances + * the REST API will refuse. This is a courtesy: enforcement lives in + * WP_REST_Comments_Controller. + */ + if ( ! empty( $block_editor_context->post ) ) { + $locked_note_actions = array(); + + foreach ( wp_get_note_lock_actions() as $note_action ) { + if ( wp_note_action_is_locked( $note_action, $block_editor_context->post ) ) { + $locked_note_actions[] = $note_action; + } + } + + $editor_settings['lockedNoteActions'] = $locked_note_actions; + } + /** * Filters the settings to pass to the block editor for all editor type. * diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 7557e9258c87f..eda79a48cbed3 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -4505,6 +4505,102 @@ function _wp_check_for_scheduled_update_comment_type() { } } +/** + * Retrieves the note actions a lock can apply to. + * + * Reopening a note is classified as `resolve`: it is the same state machine + * running in the opposite direction. + * + * @since 7.2.0 + * + * @return string[] The action names. + */ +function wp_get_note_lock_actions() { + return array( 'create', 'reply', 'edit', 'resolve', 'delete' ); +} + +/** + * Determines whether a note action is locked for a post. + * + * A lock freezes note mutation while leaving notes readable. It is expressed + * per action, so a site can either freeze a post's notes entirely or preserve + * them selectively, for example by disallowing deletion while review continues. + * + * Two layers feed the result: the `_wp_notes_locked` post meta locks every + * action on that post, and the {@see 'note_action_is_locked'} filter then + * refines the computed value. + * + * @since 7.2.0 + * + * @param string $action Note action. Accepts 'create', 'reply', 'edit', 'resolve', 'delete'. + * @param int|WP_Post $post Post ID or post object the note belongs to. + * @param WP_Comment|null $comment Optional. The note being mutated. Null when creating one. Default null. + * @return bool Whether the action is locked. + */ +function wp_note_action_is_locked( $action, $post, $comment = null ) { + $post = get_post( $post ); + + if ( ! $post instanceof WP_Post ) { + return false; + } + + $locked = (bool) get_post_meta( $post->ID, '_wp_notes_locked', true ); + + /** + * Filters whether a note action is locked. + * + * Locking freezes note mutation but never affects reading notes. Returning + * true blocks the action for every user, including administrators, unless + * the filter itself carves out an exception. + * + * @since 7.2.0 + * + * @param bool $locked Whether the action is locked. Defaults to the + * post's `_wp_notes_locked` meta value. + * @param string $action Note action. Accepts 'create', 'reply', 'edit', 'resolve', 'delete'. + * @param WP_Post $post The post the note belongs to. + * @param WP_Comment|null $comment The note being mutated, or null when creating one. + */ + return (bool) apply_filters( 'note_action_is_locked', $locked, $action, $post, $comment ); +} + +/** + * Registers the note lock meta on every post type that supports notes. + * + * Runs late on the `init` action so that post types registered at the default + * priority are already in place. + * + * @since 7.2.0 + */ +function wp_register_note_lock_meta() { + foreach ( get_post_types_by_support( 'editor' ) as $post_type ) { + if ( ! _wp_post_type_supports_notes( $post_type ) ) { + continue; + } + + register_post_meta( + $post_type, + '_wp_notes_locked', + array( + 'type' => 'boolean', + 'description' => __( 'Whether notes are locked for this post.' ), + 'single' => true, + 'default' => false, + 'show_in_rest' => true, + 'sanitize_callback' => 'rest_sanitize_boolean', + /* + * Locking is an editorial decision, so it takes more than being the + * post's author: an author locking reviewers out of their own review + * thread would defeat the point. + */ + 'auth_callback' => static function ( $allowed, $meta_key, $post_id ) { + return current_user_can( 'edit_others_posts' ) && current_user_can( 'edit_post', $post_id ); + }, + ) + ); + } +} + /** * Register initial note status meta. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 12ca0045b98b4..0ed0315dbb7ad 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -153,6 +153,9 @@ add_action( 'deleted_comment_meta', 'wp_cache_set_comments_last_changed' ); add_action( 'init', 'wp_create_initial_comment_meta' ); +// Note lock meta, registered late so that post types added on `init` are in place. +add_action( 'init', 'wp_register_note_lock_meta', 20 ); + // Places to balance tags on input. foreach ( array( 'content_save_pre', 'excerpt_save_pre', 'comment_save_pre', 'pre_comment_content' ) as $filter ) { add_filter( $filter, 'convert_invalid_entities' ); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 2db73e9a20476..c19797bb65ad2 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -2383,6 +2383,29 @@ function post_type_supports( $post_type, $feature ) { return ( isset( $_wp_post_type_features[ $post_type ][ $feature ] ) ); } + +/** + * Determines whether a post type supports notes. + * + * Notes support is declared through the arguments of the `editor` feature, as in + * `'supports' => array( 'editor' => array( 'notes' => true ) )`. + * + * @since 7.2.0 + * @access private + * + * @param string $post_type Post type name. + * @return bool Whether the post type supports notes. + */ +function _wp_post_type_supports_notes( $post_type ) { + $supports = get_all_post_type_supports( $post_type ); + + if ( ! isset( $supports['editor'] ) || ! is_array( $supports['editor'] ) ) { + return false; + } + + return array_any( $supports['editor'], static fn( $args ) => ! empty( $args['notes'] ) ); +} + /** * Retrieves a list of post type names that support a specific feature. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php index d14aefb1f6308..5d1929f7d0c25 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php @@ -146,7 +146,7 @@ public function get_items_permissions_check( $request ) { ); } - if ( $post && $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) { + if ( $post && $is_note && ! _wp_post_type_supports_notes( $post->post_type ) ) { if ( current_user_can( 'edit_post', $post->ID ) ) { return new WP_Error( 'rest_comment_not_supported_post_type', @@ -596,7 +596,7 @@ public function create_item_permissions_check( $request ) { ); } - if ( $is_note && ! $this->check_post_type_supports_notes( $post->post_type ) ) { + if ( $is_note && ! _wp_post_type_supports_notes( $post->post_type ) ) { return new WP_Error( 'rest_comment_not_supported_post_type', __( 'Sorry, this post type does not support notes.' ), @@ -604,6 +604,14 @@ public function create_item_permissions_check( $request ) { ); } + if ( $is_note ) { + $lock_check = $this->check_note_lock_permission( $request, $post ); + + if ( is_wp_error( $lock_check ) ) { + return $lock_check; + } + } + if ( 'draft' === $post->post_status && ! $is_note ) { return new WP_Error( 'rest_comment_draft_post', @@ -880,7 +888,7 @@ public function update_item_permissions_check( $request ) { ); } - return true; + return $this->check_note_lock_for_comment( $request, $comment ); } /** @@ -1028,7 +1036,8 @@ public function delete_item_permissions_check( $request ) { array( 'status' => rest_authorization_required_code() ) ); } - return true; + + return $this->check_note_lock_for_comment( $request, $comment ); } /** @@ -2042,22 +2051,97 @@ protected function check_is_comment_content_allowed( $prepared_comment ) { } /** - * Check if post type supports notes. + * Determines which note actions a request performs. * - * @param string $post_type Post type name. - * @return bool True if post type supports notes, false otherwise. + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @param WP_Comment|null $comment The targeted note, or null when creating one. + * @return string[] The actions the request performs. */ - private function check_post_type_supports_notes( $post_type ) { - $supports = get_all_post_type_supports( $post_type ); + private function get_note_request_actions( $request, $comment ) { + if ( 'DELETE' === $request->get_method() ) { + return array( 'delete' ); + } - if ( ! isset( $supports['editor'] ) ) { - return false; + // Creating a note: a resolution marker, a reply, or a new thread. + if ( ! $comment instanceof WP_Comment ) { + $meta = $request['meta']; + + if ( is_array( $meta ) && isset( $meta['_wp_note_status'] ) ) { + return array( 'resolve' ); + } + + return empty( $request['parent'] ) ? array( 'create' ) : array( 'reply' ); } - if ( ! is_array( $supports['editor'] ) ) { - return false; + $actions = array(); + + if ( null !== $request['content'] ) { + $actions[] = 'edit'; + } + + /* + * A status change is the resolve/reopen toggle rather than an edit. Only a + * change counts: re-sending the status the note already has mutates nothing. + */ + if ( null !== $request['status'] && + $this->prepare_status_response( $request['status'] ) !== $this->prepare_status_response( $comment->comment_approved ) + ) { + $actions[] = 'resolve'; + } + + // Any other field on the note (author, date, meta) counts as an edit. + return empty( $actions ) ? array( 'edit' ) : $actions; + } + + /** + * Checks whether a lock forbids what a request does to an existing comment. + * + * Anything that is not a note, and any note whose post has gone missing, is + * left to the rest of the controller to deal with. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @param WP_Comment $comment The targeted comment. + * @return true|WP_Error True when nothing is locked, error object otherwise. + */ + private function check_note_lock_for_comment( $request, $comment ) { + if ( 'note' !== $comment->comment_type ) { + return true; } - return array_any( $supports['editor'], fn( $item ) => ! empty( $item['notes'] ) ); + $post = get_post( (int) $comment->comment_post_ID ); + + if ( ! $post instanceof WP_Post ) { + return true; + } + + return $this->check_note_lock_permission( $request, $post, $comment ); + } + + /** + * Checks whether a lock forbids the note actions a request performs. + * + * @since 7.2.0 + * + * @param WP_REST_Request $request Full details about the request. + * @param WP_Post $post The post the note belongs to. + * @param WP_Comment|null $comment Optional. The targeted note. Null when creating one. Default null. + * @return true|WP_Error True when nothing is locked, error object otherwise. + */ + private function check_note_lock_permission( $request, $post, $comment = null ) { + foreach ( $this->get_note_request_actions( $request, $comment ) as $action ) { + if ( wp_note_action_is_locked( $action, $post, $comment ) ) { + return new WP_Error( + 'rest_notes_locked', + __( 'Notes are locked for this post.' ), + array( 'status' => 403 ) + ); + } + } + + return true; } } diff --git a/tests/phpunit/tests/rest-api/rest-notes-locking.php b/tests/phpunit/tests/rest-api/rest-notes-locking.php new file mode 100644 index 0000000000000..cde9862e115ed --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-notes-locking.php @@ -0,0 +1,545 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); + self::$author_id = $factory->user->create( array( 'role' => 'author' ) ); + + self::$post = $factory->post->create_and_get( + array( + 'post_author' => self::$author_id, + 'post_status' => 'publish', + ) + ); + } + + /** + * Deletes the shared fixtures. + */ + public static function wpTearDownAfterClass() { + wp_delete_post( self::$post->ID, true ); + + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + self::delete_user( self::$author_id ); + } + + public function set_up() { + parent::set_up(); + + /* + * The test case unregisters every meta key on set up, discarding what + * `init` registered. + */ + wp_register_note_lock_meta(); + + wp_set_current_user( self::$editor_id ); + } + + /** + * Locks every note action on the shared post. + */ + protected function lock_post() { + update_post_meta( self::$post->ID, '_wp_notes_locked', true ); + } + + /** + * Dispatches a request to create a note. + * + * @param array $params Optional. Extra request parameters. Default empty array. + * @return WP_REST_Response The response. + */ + protected function create_note( $params = array() ) { + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->set_param( 'post', self::$post->ID ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'content', 'A note.' ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return rest_get_server()->dispatch( $request ); + } + + /** + * Creates a note directly, bypassing the REST gate, so that a thread can be + * seeded on a post that is already locked. + * + * @param array $args Optional. Extra comment arguments. Default empty array. + * @return int The note ID. + */ + protected function seed_note( $args = array() ) { + return wp_insert_comment( + array_merge( + array( + 'comment_post_ID' => self::$post->ID, + 'comment_type' => 'note', + 'comment_content' => 'Seeded note.', + 'comment_approved' => '0', + 'user_id' => self::$editor_id, + ), + $args + ) + ); + } + + /** + * Dispatches a request to update a note. + * + * @param int $note_id Note ID. + * @param array $params Request parameters. + * @return WP_REST_Response The response. + */ + protected function update_note( $note_id, $params ) { + $request = new WP_REST_Request( 'POST', '/wp/v2/comments/' . $note_id ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return rest_get_server()->dispatch( $request ); + } + + /** + * Dispatches a request to delete a note. + * + * @param int $note_id Note ID. + * @param bool $force Optional. Whether to bypass the trash. Default true. + * @return WP_REST_Response The response. + */ + protected function delete_note( $note_id, $force = true ) { + $request = new WP_REST_Request( 'DELETE', '/wp/v2/comments/' . $note_id ); + $request->set_param( 'force', $force ); + + return rest_get_server()->dispatch( $request ); + } + + /** + * Records every `note_action_is_locked` call, leaving the value untouched. + * + * @param bool $locked Whether the action is locked. + * @param string $action The action name. + * @param WP_Post $post The post. + * @param WP_Comment|null $comment The note being mutated, if there is one. + * @return bool The unchanged value. + */ + public function record_filter_call( $locked, $action, $post, $comment ) { + $this->filter_calls[] = array( $locked, $action, $post, $comment ); + + return $locked; + } + + /** + * Locks deletion and nothing else. + * + * @param bool $locked Whether the action is locked. + * @param string $action The action name. + * @return bool Whether the action is locked. + */ + public function lock_deletion_only( $locked, $action ) { + return 'delete' === $action ? true : $locked; + } + + /** + * Every note flow works when nothing is locked. + */ + public function test_unlocked_post_allows_every_note_action() { + $created = $this->create_note(); + $this->assertSame( 201, $created->get_status(), 'The note should have been created.' ); + $note_id = $created->get_data()['id']; + + $reply = $this->create_note( + array( + 'parent' => $note_id, + 'content' => 'A reply.', + ) + ); + $this->assertSame( 201, $reply->get_status(), 'The reply should have been created.' ); + + $edited = $this->update_note( $note_id, array( 'content' => 'An edited note.' ) ); + $this->assertSame( 200, $edited->get_status(), 'The note should have been edited.' ); + + $resolved = $this->update_note( $note_id, array( 'status' => 'approved' ) ); + $this->assertSame( 200, $resolved->get_status(), 'The note should have been resolved.' ); + + $marker = $this->create_note( + array( + 'parent' => $note_id, + 'content' => '', + 'status' => 'approved', + 'meta' => array( '_wp_note_status' => 'resolved' ), + ) + ); + $this->assertSame( 201, $marker->get_status(), 'The resolution marker note should have been created.' ); + + $deleted = $this->delete_note( $note_id ); + $this->assertSame( 200, $deleted->get_status(), 'The note should have been deleted.' ); + } + + /** + * The per-post meta locks every mutation. + */ + public function test_locked_post_rejects_every_note_mutation() { + $note_id = $this->seed_note(); + $reply_id = $this->seed_note( array( 'comment_parent' => $note_id ) ); + + $this->lock_post(); + + $this->assertErrorResponse( 'rest_notes_locked', $this->create_note(), 403, 'Note creation should be locked.' ); + $this->assertErrorResponse( + 'rest_notes_locked', + $this->create_note( array( 'parent' => $note_id ) ), + 403, + 'Replies should be locked.' + ); + $this->assertErrorResponse( + 'rest_notes_locked', + $this->create_note( + array( + 'parent' => $note_id, + 'content' => '', + 'status' => 'approved', + 'meta' => array( '_wp_note_status' => 'resolved' ), + ) + ), + 403, + 'Resolution marker notes should be locked.' + ); + $this->assertErrorResponse( + 'rest_notes_locked', + $this->update_note( $note_id, array( 'content' => 'Edited.' ) ), + 403, + 'Note edits should be locked.' + ); + $this->assertErrorResponse( + 'rest_notes_locked', + $this->update_note( $note_id, array( 'status' => 'approved' ) ), + 403, + 'Resolving should be locked.' + ); + $this->assertErrorResponse( + 'rest_notes_locked', + $this->delete_note( $reply_id, false ), + 403, + 'Trashing a reply should be locked.' + ); + $this->assertErrorResponse( + 'rest_notes_locked', + $this->delete_note( $note_id ), + 403, + 'Deleting a note should be locked.' + ); + } + + /** + * Administrators are bound by the lock too. + */ + public function test_lock_binds_administrators() { + $note_id = $this->seed_note(); + $this->lock_post(); + + wp_set_current_user( self::$admin_id ); + + $this->assertErrorResponse( + 'rest_notes_locked', + $this->create_note(), + 403, + 'Note creation should be locked for administrators.' + ); + $this->assertErrorResponse( + 'rest_notes_locked', + $this->delete_note( $note_id ), + 403, + 'Deletion should be locked for administrators.' + ); + } + + /** + * Regular comments are untouched by a lock. + */ + public function test_lock_does_not_affect_regular_comments() { + $this->lock_post(); + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->set_param( 'post', self::$post->ID ); + $request->set_param( 'content', 'A regular comment.' ); + $created = rest_get_server()->dispatch( $request ); + + $this->assertSame( 201, $created->get_status(), 'The comment should have been created.' ); + $comment_id = $created->get_data()['id']; + + $edited = $this->update_note( $comment_id, array( 'content' => 'An edited comment.' ) ); + $this->assertSame( 200, $edited->get_status(), 'The comment should have been edited.' ); + + $deleted = $this->delete_note( $comment_id ); + $this->assertSame( 200, $deleted->get_status(), 'The comment should have been deleted.' ); + } + + /** + * Reading notes stays open on a locked post. + */ + public function test_lock_does_not_affect_reading_notes() { + $note_id = $this->seed_note(); + $this->lock_post(); + + $request = new WP_REST_Request( 'GET', '/wp/v2/comments' ); + $request->set_param( 'post', self::$post->ID ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'status', 'all' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Notes should be readable.' ); + $this->assertSame( + array( $note_id ), + wp_list_pluck( $response->get_data(), 'id' ), + 'The seeded note should have been listed.' + ); + } + + /** + * The filter can lock notes site-wide, with no meta set. + */ + public function test_filter_can_lock_every_post() { + add_filter( 'note_action_is_locked', '__return_true' ); + + $this->assertErrorResponse( + 'rest_notes_locked', + $this->create_note(), + 403, + 'The site-wide filter should have locked note creation.' + ); + } + + /** + * The filter can lock a single action, and receives the expected arguments. + */ + public function test_filter_can_lock_a_single_action() { + $note_id = $this->seed_note(); + + add_filter( 'note_action_is_locked', array( $this, 'record_filter_call' ), 5, 4 ); + add_filter( 'note_action_is_locked', array( $this, 'lock_deletion_only' ), 10, 2 ); + + $created = $this->create_note(); + $this->assertSame( 201, $created->get_status(), 'Creation should have stayed open.' ); + + $edited = $this->update_note( $note_id, array( 'content' => 'Edited.' ) ); + $this->assertSame( 200, $edited->get_status(), 'Editing should have stayed open.' ); + + $resolved = $this->update_note( $note_id, array( 'status' => 'approved' ) ); + $this->assertSame( 200, $resolved->get_status(), 'Resolving should have stayed open.' ); + + $this->assertErrorResponse( + 'rest_notes_locked', + $this->delete_note( $note_id ), + 403, + 'Deletion should have been locked.' + ); + + $this->assertSame( + array( 'create', 'edit', 'resolve', 'delete' ), + wp_list_pluck( $this->filter_calls, 1 ), + 'The filter should have classified each request.' + ); + + list( $locked, , $post, $comment ) = $this->filter_calls[0]; + $this->assertFalse( $locked, 'The default should have been unlocked.' ); + $this->assertSame( self::$post->ID, $post->ID, 'The filter should have received the target post.' ); + $this->assertNull( $comment, 'There should have been no comment on create.' ); + + list( , , , $delete_comment ) = $this->filter_calls[3]; + $this->assertInstanceOf( WP_Comment::class, $delete_comment, 'The filter should have received the note.' ); + $this->assertSame( (string) $note_id, $delete_comment->comment_ID, 'The targeted note should have been passed.' ); + } + + /** + * The filter can exempt a capability from a lock. + */ + public function test_filter_can_exempt_a_capability() { + $editors_note = $this->seed_note(); + $admins_note = $this->seed_note(); + + $this->lock_post(); + add_filter( + 'note_action_is_locked', + static function ( $locked, $action ) { + return 'delete' === $action && current_user_can( 'manage_options' ) ? false : $locked; + }, + 10, + 2 + ); + + $this->assertErrorResponse( + 'rest_notes_locked', + $this->delete_note( $editors_note ), + 403, + 'The editor should have been blocked from deleting.' + ); + + wp_set_current_user( self::$admin_id ); + $deleted = $this->delete_note( $admins_note ); + $this->assertSame( 200, $deleted->get_status(), 'The administrator should have deleted the note.' ); + } + + /** + * Writing the lock meta takes more than authoring the post. + */ + public function test_lock_meta_write_requires_edit_others_posts() { + wp_set_current_user( self::$author_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/posts/' . self::$post->ID ); + $request->set_param( 'meta', array( '_wp_notes_locked' => true ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 403, $response->get_status(), 'The author should have been refused.' ); + $this->assertFalse( + (bool) get_post_meta( self::$post->ID, '_wp_notes_locked', true ), + 'The post should have stayed unlocked.' + ); + + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/posts/' . self::$post->ID ); + $request->set_param( 'meta', array( '_wp_notes_locked' => true ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'The editor should have locked the post.' ); + $this->assertTrue( + $response->get_data()['meta']['_wp_notes_locked'], + 'The lock should have round-tripped as a boolean.' + ); + } + + /** + * The meta is only registered for post types that support notes. + */ + public function test_lock_meta_is_only_registered_for_note_capable_post_types() { + register_post_type( 'wptests_no_notes', array( 'supports' => array( 'editor' ) ) ); + wp_register_note_lock_meta(); + + $this->assertTrue( + registered_meta_key_exists( 'post', '_wp_notes_locked', 'post' ), + 'The meta should be registered for a post type that supports notes.' + ); + $this->assertFalse( + registered_meta_key_exists( 'post', '_wp_notes_locked', 'wptests_no_notes' ), + 'The meta should not be registered for a post type without notes support.' + ); + + unregister_post_type( 'wptests_no_notes' ); + } + + /** + * The permission checks leave targets they cannot resolve to the controller. + */ + public function test_lock_leaves_unresolvable_targets_alone() { + add_filter( 'note_action_is_locked', '__return_true' ); + wp_set_current_user( self::$admin_id ); + + $missing = $this->delete_note( 999999 ); + $this->assertSame( + 'rest_comment_invalid_id', + $missing->as_error()->get_error_code(), + 'A missing comment should have been left to the controller.' + ); + + $orphan = $this->seed_note( array( 'comment_post_ID' => 999999 ) ); + $response = $this->delete_note( $orphan ); + $this->assertNotSame( 500, $response->get_status(), 'An orphaned note should not have been fatal.' ); + $this->assertNotSame( + 'rest_notes_locked', + is_wp_error( $response->as_error() ) ? $response->as_error()->get_error_code() : '', + 'A note whose post is gone should have been left to the controller.' + ); + } + + /** + * The editor settings advertise the locked actions. + * + * @covers ::get_block_editor_settings + */ + public function test_editor_settings_expose_locked_actions() { + $context = new WP_Block_Editor_Context( array( 'post' => self::$post ) ); + + $settings = get_block_editor_settings( array(), $context ); + $this->assertSame( array(), $settings['lockedNoteActions'], 'Nothing should be locked by default.' ); + + $this->lock_post(); + $settings = get_block_editor_settings( array(), $context ); + $this->assertSame( + array( 'create', 'reply', 'edit', 'resolve', 'delete' ), + $settings['lockedNoteActions'], + 'Every action should have been locked.' + ); + + delete_post_meta( self::$post->ID, '_wp_notes_locked' ); + add_filter( 'note_action_is_locked', array( $this, 'lock_deletion_only' ), 10, 2 ); + + $settings = get_block_editor_settings( array(), $context ); + $this->assertSame( + array( 'delete' ), + $settings['lockedNoteActions'], + 'Only the filtered action should have been locked.' + ); + } +}