diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 7557e9258c87f..993a00eac3fc1 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -4506,9 +4506,10 @@ function _wp_check_for_scheduled_update_comment_type() { } /** - * Register initial note status meta. + * Register initial note meta. * * @since 6.9.0 + * @since 7.2.0 Registers the `_wp_note_anchor` meta. */ function wp_create_initial_comment_meta() { register_meta( @@ -4529,6 +4530,33 @@ function wp_create_initial_comment_meta() { }, ) ); + + /* + * Notes anchor to a block through the block's own `metadata.noteId` + * attribute, which only works where there is persisted block content to + * write to. Surfaces without it - the Style Book, whose examples are + * generated fresh on every render - record what the note is about here + * instead, as an opaque identifier the surface defines and resolves. + */ + register_meta( + 'comment', + '_wp_note_anchor', + array( + 'type' => 'string', + 'description' => __( 'What the note is anchored to, for notes not anchored to a block.' ), + 'single' => true, + 'sanitize_callback' => 'sanitize_text_field', + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'string', + 'maxLength' => 100, + ), + ), + 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + return current_user_can( 'edit_comment', $object_id ); + }, + ) + ); } /** diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 2db73e9a20476..386cca7d79e3e 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -520,7 +520,7 @@ function create_initial_post_types() { 'map_meta_cap' => true, 'supports' => array( 'title', - 'editor', + 'editor' => array( 'notes' => true ), 'revisions', ), ) diff --git a/tests/phpunit/tests/rest-api/rest-global-styles-notes.php b/tests/phpunit/tests/rest-api/rest-global-styles-notes.php new file mode 100644 index 0000000000000..3d0825fa2779a --- /dev/null +++ b/tests/phpunit/tests/rest-api/rest-global-styles-notes.php @@ -0,0 +1,361 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$author_id = $factory->user->create( array( 'role' => 'author' ) ); + } + + /** + * Deletes shared users. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$author_id ); + } + + /** + * Re-registers the note meta, which the test case unregisters after each + * test and which is otherwise only registered on `init`. + */ + public function set_up() { + parent::set_up(); + + wp_create_initial_comment_meta(); + } + + /** + * Returns the current theme's user global styles post ID, creating the + * record if the theme has not been customized yet. + * + * The resolver caches per theme across tests, so the cache is reset first + * to keep each test independent of the order it runs in. + * + * @return int Post ID. + */ + private function get_global_styles_post_id() { + WP_Theme_JSON_Resolver::clean_cached_data(); + + $post = WP_Theme_JSON_Resolver::get_user_data_from_wp_global_styles( wp_get_theme(), true ); + + return isset( $post['ID'] ) ? (int) $post['ID'] : 0; + } + + /** + * Builds a note creation request. + * + * @param int $post_id Post to attach the note to. + * @param string $anchor Anchor identifier. + * @return WP_REST_Request Prepared request. + */ + private function note_request( $post_id, $anchor = 'core/button' ) { + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_param( 'post', $post_id ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'status', 'hold' ); + $request->set_param( 'content', 'Make this button rounder.' ); + $request->set_param( 'meta', array( self::ANCHOR_META => $anchor ) ); + + return $request; + } + + /** + * The post type declares notes support in the shape the comments + * controller reads. + * + * `WP_REST_Comments_Controller::check_post_type_supports_notes()` looks for + * a truthy `notes` key inside the `editor` support arguments, so replicate + * that read rather than asserting on the raw array shape. + * + * @ticket 65872 + */ + public function test_global_styles_supports_notes() { + $supports = get_all_post_type_supports( 'wp_global_styles' ); + + $this->assertArrayHasKey( 'editor', $supports, 'wp_global_styles should still declare editor support.' ); + $this->assertIsArray( $supports['editor'], 'Editor support should carry arguments.' ); + $this->assertTrue( + array_any( $supports['editor'], static fn( $item ) => ! empty( $item['notes'] ) ), + 'wp_global_styles should declare notes support.' + ); + } + + /** + * Adding support arguments must not break plain `editor` support, which + * only tests for the key's presence. + * + * @ticket 65872 + */ + public function test_global_styles_still_supports_editor() { + $this->assertTrue( post_type_supports( 'wp_global_styles', 'editor' ) ); + } + + /** + * The anchor meta is registered for comments and exposed over REST. + * + * @ticket 65872 + */ + public function test_anchor_meta_is_registered() { + $this->assertTrue( registered_meta_key_exists( 'comment', self::ANCHOR_META ) ); + + $registered = get_registered_meta_keys( 'comment' ); + + $this->assertArrayHasKey( self::ANCHOR_META, $registered ); + $this->assertTrue( $registered[ self::ANCHOR_META ]['single'] ); + $this->assertNotEmpty( $registered[ self::ANCHOR_META ]['show_in_rest'] ); + } + + /** + * An administrator can create a note on global styles, and the anchor + * round-trips through the response. + * + * @ticket 65872 + */ + public function test_administrator_can_create_note_with_anchor() { + wp_set_current_user( self::$admin_id ); + + $post_id = $this->get_global_styles_post_id(); + $response = rest_get_server()->dispatch( $this->note_request( $post_id ) ); + + $this->assertSame( 201, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertSame( $post_id, $data['post'] ); + $this->assertSame( 'note', $data['type'] ); + $this->assertSame( 'core/button', $data['meta'][ self::ANCHOR_META ] ); + $this->assertSame( 'core/button', get_comment_meta( $data['id'], self::ANCHOR_META, true ) ); + } + + /** + * The anchor is an opaque string the anchoring surface defines, so block + * names from any source and synthetic section names are all valid. + * + * @ticket 65872 + * + * @dataProvider data_valid_anchors + * + * @param string $anchor Anchor identifier. + */ + public function test_anchor_accepts_arbitrary_identifiers( $anchor ) { + wp_set_current_user( self::$admin_id ); + + $response = rest_get_server()->dispatch( + $this->note_request( $this->get_global_styles_post_id(), $anchor ) + ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( $anchor, $response->get_data()['meta'][ self::ANCHOR_META ] ); + } + + /** + * Data provider for anchor names. + * + * @return array Test parameters. + */ + public function data_valid_anchors() { + return array( + 'core block' => array( 'core/button' ), + 'third-party' => array( 'my-plugin/testimonial' ), + 'typography' => array( 'typography' ), + 'color group' => array( 'theme-colors' ), + 'duotone group' => array( 'duotones' ), + ); + } + + /** + * A user without `edit_theme_options` cannot create notes on global styles. + * An author has `edit_posts`, so this exercises the controller's per-post + * `edit_post` check rather than a blanket capability gate. + * + * @ticket 65872 + */ + public function test_author_cannot_create_note() { + wp_set_current_user( self::$author_id ); + + $response = rest_get_server()->dispatch( + $this->note_request( $this->get_global_styles_post_id() ) + ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertSame( 'rest_cannot_create_note', $response->get_data()['code'] ); + } + + /** + * Logged-out requests cannot create notes on global styles. + * + * @ticket 65872 + */ + public function test_anonymous_cannot_create_note() { + $post_id = $this->get_global_styles_post_id(); + + wp_set_current_user( 0 ); + + $response = rest_get_server()->dispatch( $this->note_request( $post_id ) ); + + $this->assertSame( 401, $response->get_status() ); + } + + /** + * An administrator can list notes on global styles in edit context. + * + * @ticket 65872 + */ + public function test_administrator_can_list_notes() { + wp_set_current_user( self::$admin_id ); + + $post_id = $this->get_global_styles_post_id(); + rest_get_server()->dispatch( $this->note_request( $post_id, 'typography' ) ); + + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'post', $post_id ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'status', 'all' ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $anchors = wp_list_pluck( wp_list_pluck( $response->get_data(), 'meta' ), self::ANCHOR_META ); + + $this->assertContains( 'typography', $anchors ); + } + + /** + * Listing notes requires `edit_theme_options`; an author with `edit_posts` + * is still refused. + * + * @ticket 65872 + */ + public function test_author_cannot_list_notes() { + $post_id = $this->get_global_styles_post_id(); + + wp_set_current_user( self::$author_id ); + + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'post', $post_id ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'status', 'all' ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_forbidden_context', $response, 403 ); + } + + /** + * Logged-out requests cannot list notes on global styles. + * + * @ticket 65872 + */ + public function test_anonymous_cannot_list_notes() { + $post_id = $this->get_global_styles_post_id(); + + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', self::ROUTE ); + $request->set_param( 'post', $post_id ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'status', 'all' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 401, $response->get_status() ); + } + + /** + * The anchor is sanitized, so markup in the value cannot survive to the + * client. + * + * @ticket 65872 + */ + public function test_anchor_is_sanitized() { + wp_set_current_user( self::$admin_id ); + + $response = rest_get_server()->dispatch( + $this->note_request( $this->get_global_styles_post_id(), 'core/button' ) + ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'core/button', $response->get_data()['meta'][ self::ANCHOR_META ] ); + } + + /** + * Anchors longer than the schema's `maxLength` are rejected rather than + * silently truncated. + * + * @ticket 65872 + */ + public function test_overlong_anchor_is_rejected() { + wp_set_current_user( self::$admin_id ); + + $response = rest_get_server()->dispatch( + $this->note_request( $this->get_global_styles_post_id(), str_repeat( 'a', 101 ) ) + ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'rest_too_long', $response->get_data()['code'] ); + } + + /** + * Notes are still refused on post types that do not declare notes support, + * so enabling them for global styles did not widen the gate. + * + * @ticket 65872 + */ + public function test_unsupported_post_type_still_rejects_notes() { + wp_set_current_user( self::$admin_id ); + + $page_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + remove_post_type_support( 'page', 'editor' ); + + $response = rest_get_server()->dispatch( $this->note_request( $page_id ) ); + + add_post_type_support( 'page', 'editor' ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertSame( 'rest_comment_not_supported_post_type', $response->get_data()['code'] ); + } +}