diff --git a/src/wp-admin/edit-form-blocks.php b/src/wp-admin/edit-form-blocks.php index 820424fb3c477..59eb27ab5e1cd 100644 --- a/src/wp-admin/edit-form-blocks.php +++ b/src/wp-admin/edit-form-blocks.php @@ -68,7 +68,8 @@ static function ( $classes ) { array( rest_get_route_for_post_type_items( 'page' ), 'OPTIONS' ), array( rest_get_route_for_post_type_items( 'wp_block' ), 'OPTIONS' ), array( rest_get_route_for_post_type_items( 'wp_template' ), 'OPTIONS' ), - sprintf( '%s/autosaves?context=edit', $rest_path ), + // The editor requests only the current user's autosave. + sprintf( '%s/autosaves?context=edit&author=%d', $rest_path, get_current_user_id() ), '/wp/v2/settings', array( '/wp/v2/settings', 'OPTIONS' ), '/wp/v2/global-styles/themes/' . $active_theme . '?context=view', diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index c0d58160467b2..1b978f8f7d39d 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -302,11 +302,14 @@ public function get_item( $request ) { } /** - * Gets a collection of autosaves using wp_get_post_autosave. + * Gets a collection of autosaves for the parent post. * - * Contains the user's autosave, for empty if it doesn't exist. + * WordPress stores at most one autosave per author, so this contains one + * entry per author who has autosaved the parent post, newest first. * * @since 5.0.0 + * @since 7.3.0 Autosaves are queried directly rather than filtered out of + * every revision of the parent. Added the `author` parameter. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. @@ -321,21 +324,49 @@ public function get_items( $request ) { // Return early as this handler doesn't add any response headers. return new WP_REST_Response( array() ); } + $response = array(); $parent_id = $parent->ID; - $revisions = wp_get_post_revisions( $parent_id, array( 'check_enabled' => false ) ); - foreach ( $revisions as $revision ) { - if ( str_contains( $revision->post_name, "{$parent_id}-autosave" ) ) { - $data = $this->prepare_item_for_response( $revision, $request ); - $response[] = $this->prepare_response_for_collection( $data ); + $query_args = array( + 'post_type' => 'revision', + 'post_status' => 'inherit', + 'post_parent' => $parent_id, + // Unlike `name`, this does not make the query singular. + 'post_name__in' => array( $parent_id . '-autosave-v1' ), + 'orderby' => array( + 'date' => 'DESC', + 'ID' => 'DESC', + ), + 'posts_per_page' => -1, + // Matches the isolation of the wp_get_post_revisions() lookup this replaces. + 'suppress_filters' => true, + ); + + if ( ! empty( $request['author'] ) ) { + $query_args['author__in'] = $request['author']; + } + + $autosaves = get_posts( $query_args ); + + // Each author has at most one autosave; keep only the newest if not. + $seen_authors = array(); + + foreach ( $autosaves as $autosave ) { + if ( in_array( $autosave->post_author, $seen_authors, true ) ) { + continue; } + $seen_authors[] = $autosave->post_author; + + $data = $this->prepare_item_for_response( $autosave, $request ); + $response[] = $this->prepare_response_for_collection( $data ); } return rest_ensure_response( $response ); } + /** * Retrieves the autosave's schema, conforming to JSON Schema. * @@ -507,12 +538,22 @@ public function prepare_item_for_response( $item, $request ) { * Retrieves the query params for the autosaves collection. * * @since 5.0.0 + * @since 7.3.0 Added the `author` parameter. * * @return array Collection parameters. */ public function get_collection_params() { return array( 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'author' => array( + 'description' => __( 'Limit result set to autosaves assigned to specific authors.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'default' => array(), + ), ); } } diff --git a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php index be6a1996ee98a..8bd0857b84e16 100644 --- a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php +++ b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php @@ -25,6 +25,11 @@ class WP_Test_REST_Autosaves_Controller extends WP_Test_REST_Post_Type_Controlle private WP_Post $post_autosave; + /** + * @var list + */ + protected static array $extra_users; + protected function set_post_data( $args = array() ) { $defaults = array( 'title' => 'Post Title', @@ -108,6 +113,15 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { 'post_author' => self::$editor_id, ) ); + self::$extra_users = array(); + for ( $i = 0; $i < 3; $i++ ) { + // Additional editors, each of whom gets their own autosave on the shared post. + self::$extra_users[] = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + } } public static function wpTearDownAfterClass() { @@ -117,6 +131,10 @@ public static function wpTearDownAfterClass() { self::delete_user( self::$editor_id ); self::delete_user( self::$contributor_id ); + + foreach ( self::$extra_users as $user_id ) { + self::delete_user( $user_id ); + } } public function set_up() { @@ -159,6 +177,7 @@ public function test_registered_query_params() { sort( $keys ); $this->assertSame( array( + 'author', 'context', 'parent', ), @@ -1074,4 +1093,382 @@ public static function data_head_request_with_specified_fields_returns_success_r 'get_items request' => array( '/wp/v2/posts/%d' ), ); } + + /** + * Creates an autosave on the shared post for each of the extra users. + * + * @return array Autosave ID keyed by the ID of its author. + */ + private function create_autosaves_for_extra_users(): array { + $autosave_ids = array(); + + foreach ( self::$extra_users as $i => $user_id ) { + wp_set_current_user( $user_id ); + $autosave_id = wp_create_post_autosave( + array( + 'post_content' => "Autosave content {$i}", + 'post_ID' => self::$post_id, + 'post_type' => 'post', + ) + ); + $this->assertIsInt( $autosave_id ); + + $autosave_ids[ $user_id ] = $autosave_id; + } + + return $autosave_ids; + } + + /** + * Sets a post's local and GMT dates directly, so tests can order records + * independently of their IDs. + * + * @param int $post_id Post ID. + * @param string $date Date in MySQL format. + */ + private function set_post_date( int $post_id, string $date ): void { + global $wpdb; + $this->assertInstanceOf( wpdb::class, $wpdb ); + + $wpdb->update( + $wpdb->posts, + array( + 'post_date' => $date, + 'post_date_gmt' => $date, + ), + array( 'ID' => $post_id ) + ); + clean_post_cache( $post_id ); + } + + /** + * Returns one integer field from each item in a collection response. + * + * @param WP_REST_Response $response Collection response. + * @param string $field Field to collect. + * @return list The field's value for each item, in response order. + */ + private function get_collection_field( WP_REST_Response $response, string $field = 'id' ): array { + $data = $response->get_data(); + $this->assertIsArray( $data ); + + $values = array(); + foreach ( $data as $item ) { + $this->assertIsArray( $item ); + $this->assertArrayHasKey( $field, $item ); + $this->assertIsInt( $item[ $field ] ); + + $values[] = $item[ $field ]; + } + + return $values; + } + + /** + * Tests that the collection is unscoped without an author. + * + * @ticket 62057 + */ + public function test_get_items_returns_every_author_by_default(): void { + $autosave_ids = $this->create_autosaves_for_extra_users(); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( count( $autosave_ids ) + 1, $this->get_collection_field( $response ), 'The extra users\' autosaves and the one created in set_up should all be returned.' ); + } + + /** + * Tests that an author scoped request returns only that author's autosave. + * + * WordPress stores one autosave per author, so this is the single record + * a client reading one user's autosave is after. + * + * @ticket 62057 + */ + public function test_get_items_scoped_to_one_author(): void { + $autosave_ids = $this->create_autosaves_for_extra_users(); + $author_id = self::$extra_users[1]; + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->set_param( 'author', $author_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array( $autosave_ids[ $author_id ] ), $this->get_collection_field( $response ) ); + $this->assertSame( array( $author_id ), $this->get_collection_field( $response, 'author' ) ); + } + + /** + * Tests that the author parameter accepts more than one id. + * + * @ticket 62057 + */ + public function test_get_items_scoped_to_several_authors(): void { + $autosave_ids = $this->create_autosaves_for_extra_users(); + $author_ids = array( self::$extra_users[0], self::$extra_users[2] ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->set_param( 'author', $author_ids ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSameSets( + array( $autosave_ids[ $author_ids[0] ], $autosave_ids[ $author_ids[1] ] ), + $this->get_collection_field( $response ) + ); + } + + /** + * Tests that an author without an autosave gets an empty collection rather + * than somebody else's record. + * + * @ticket 62057 + */ + public function test_get_items_for_an_author_with_no_autosave(): void { + $this->create_autosaves_for_extra_users(); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->set_param( 'author', self::$contributor_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array(), $response->get_data() ); + } + + /** + * Tests that revisions newer than the autosave do not displace it. + * + * Narrowing the underlying revision query by count rather than by author + * returns whatever is most recent, which on a post with revision history + * is usually not an autosave at all. + * + * @ticket 62057 + */ + public function test_get_items_scoped_to_one_author_past_newer_revisions(): void { + $autosave_ids = $this->create_autosaves_for_extra_users(); + $author_id = self::$extra_users[0]; + + wp_set_current_user( self::$editor_id ); + $updated = wp_update_post( + array( + 'ID' => self::$post_id, + 'post_content' => 'A revision made after every autosave.', + ) + ); + $this->assertSame( self::$post_id, $updated, 'The post should be updated.' ); + + // Revisions come back newest first, so this is the one just created. + $revisions = wp_get_post_revisions( self::$post_id, array( 'check_enabled' => false ) ); + $revision = array_first( $revisions ); + $this->assertInstanceOf( WP_Post::class, $revision ); + $this->set_post_date( $revision->ID, '2030-01-01 00:00:00' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->set_param( 'author', $author_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array( $autosave_ids[ $author_id ] ), $this->get_collection_field( $response ) ); + } + + /** + * Tests that author IDs below 1 are rejected rather than widening the query. + * + * @ticket 62057 + * + * @dataProvider data_get_items_rejects_invalid_author + * + * @param mixed $author Value passed as the author parameter. + */ + public function test_get_items_rejects_invalid_author( $author ): void { + $this->create_autosaves_for_extra_users(); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->set_param( 'author', $author ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + /** + * Data provider for test_get_items_rejects_invalid_author(). + * + * @return array> + */ + public function data_get_items_rejects_invalid_author(): array { + return array( + 'zero' => array( 0 ), + 'negative' => array( -1 ), + 'zero in a list' => array( array( 1, 0 ) ), + 'negative in a list' => array( array( 1, -1 ) ), + ); + } + + /** + * Tests that a repeated author ID returns their autosave once. + * + * @ticket 62057 + */ + public function test_get_items_with_repeated_author(): void { + $autosave_ids = $this->create_autosaves_for_extra_users(); + $author_id = self::$extra_users[1]; + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->set_param( 'author', array( $author_id, $author_id ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array( $autosave_ids[ $author_id ] ), $this->get_collection_field( $response ) ); + } + + /** + * Tests that author scoped results share the unscoped ordering, newest first. + * + * @ticket 62057 + */ + public function test_get_items_scoped_to_authors_matches_unscoped_order(): void { + $autosave_ids = array_values( $this->create_autosaves_for_extra_users() ); + + // The oldest ID gets the newest date, and the other two tie on date. + $this->set_post_date( $autosave_ids[0], '2030-01-02 00:00:00' ); + $this->set_post_date( $autosave_ids[1], '2030-01-01 00:00:00' ); + $this->set_post_date( $autosave_ids[2], '2030-01-01 00:00:00' ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $expected = array_values( + array_intersect( $this->get_collection_field( $response ), $autosave_ids ) + ); + + $request->set_param( 'author', self::$extra_users ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $expected, $this->get_collection_field( $response ) ); + $this->assertSame( + array( $autosave_ids[0], $autosave_ids[2], $autosave_ids[1] ), + $expected, + 'Autosaves should be ordered by date, newest first, then by ID.' + ); + } + + /** + * Tests that only the newest of several stored autosaves for one author is returned. + * + * @ticket 62057 + * + * @dataProvider data_get_items_with_several_autosaves_for_one_author + * + * @param non-falsy-string $first_date Date of the autosave created first. + * @param non-falsy-string $second_date Date of the autosave created second. + * @param non-negative-int $expected Index of the autosave expected to be returned. + */ + public function test_get_items_with_several_autosaves_for_one_author( string $first_date, string $second_date, int $expected ): void { + $author_id = self::$extra_users[0]; + + wp_set_current_user( $author_id ); + $first_autosave_id = wp_create_post_autosave( + array( + 'post_content' => 'First autosave.', + 'post_ID' => self::$post_id, + 'post_type' => 'post', + ) + ); + $this->assertIsInt( $first_autosave_id ); + + $autosave_ids = array( + $first_autosave_id, + // wp_create_post_autosave() updates the existing record, so insert the duplicate directly. + self::factory()->post->create( + array( + 'post_type' => 'revision', + 'post_status' => 'inherit', + 'post_parent' => self::$post_id, + 'post_name' => self::$post_id . '-autosave-v1', + 'post_author' => $author_id, + 'post_content' => 'Second autosave.', + ) + ), + ); + + $this->set_post_date( $autosave_ids[0], $first_date ); + $this->set_post_date( $autosave_ids[1], $second_date ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $request->set_param( 'author', $author_id ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( array( $autosave_ids[ $expected ] ), $this->get_collection_field( $response ) ); + } + + /** + * Data provider for test_get_items_with_several_autosaves_for_one_author(). + * + * @return array + */ + public function data_get_items_with_several_autosaves_for_one_author(): array { + return array( + 'older ID has the newer date' => array( '2030-01-02 00:00:00', '2030-01-01 00:00:00', 0 ), + 'same date, newer ID wins' => array( '2030-01-01 00:00:00', '2030-01-01 00:00:00', 1 ), + ); + } + + /** + * Tests that query filters do not apply to the autosaves lookup. + * + * The lookup previously went through get_posts(), which suppresses them. + * + * @ticket 62057 + * + * @dataProvider data_get_items_is_isolated_from_query_filters + * + * @param bool $scoped Whether to scope the request to the extra users. + */ + public function test_get_items_is_isolated_from_query_filters( bool $scoped ): void { + $autosave_ids = $this->create_autosaves_for_extra_users(); + + $filter_calls = 0; + $filter = static function ( string $clause ) use ( &$filter_calls ): string { + ++$filter_calls; + return $clause . ' AND 1=0'; + }; + add_filter( 'posts_where', $filter ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + if ( $scoped ) { + $request->set_param( 'author', self::$extra_users ); + } + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 0, $filter_calls, 'The posts_where filter should not run.' ); + $this->assertSameSets( + array_values( $autosave_ids ), + array_intersect( $this->get_collection_field( $response ), $autosave_ids ) + ); + } + + /** + * Data provider for test_get_items_is_isolated_from_query_filters(). + * + * @return array + */ + public function data_get_items_is_isolated_from_query_filters(): array { + return array( + 'unscoped' => array( false ), + 'author scoped' => array( true ), + ); + } } diff --git a/tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php b/tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php index 972cdccdb034d..2a4a471171d53 100644 --- a/tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php +++ b/tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php @@ -888,4 +888,37 @@ public function test_delete_item() { $this->assertErrorResponse( 'rest_template_not_found', $response, 404 ); } + + /** + * Tests that the inherited author parameter scopes template autosaves. + * + * @ticket 62057 + * + * @covers WP_REST_Autosaves_Controller::get_items + */ + public function test_get_items_scoped_to_author(): void { + $other_admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $autosave_ids = array(); + + foreach ( array( self::$admin_id, $other_admin_id ) as $user_id ) { + wp_set_current_user( $user_id ); + $autosave_ids[ $user_id ] = wp_create_post_autosave( + array( + 'post_content' => "Autosave content for {$user_id}.", + 'post_ID' => self::$template_post->ID, + 'post_type' => self::PARENT_POST_TYPE, + ) + ); + } + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/' . self::TEST_THEME . '//' . self::TEMPLATE_NAME . '/autosaves' ); + $request->set_param( 'author', $other_admin_id ); + $response = rest_get_server()->dispatch( $request ); + $autosaves = $response->get_data(); + + $this->assertSame( WP_Http::OK, $response->get_status(), 'Response is expected to have a status code of 200.' ); + $this->assertIsArray( $autosaves ); + $this->assertSame( array( $autosave_ids[ $other_admin_id ] ), wp_list_pluck( $autosaves, 'wp_id' ), 'Only the requested author\'s autosave should be returned.' ); + } } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 807eda9d2cf92..87cb7ef8771bb 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -1378,6 +1378,16 @@ mockedApiResponse.Schema = { ], "default": "view", "required": false + }, + "author": { + "description": "Limit result set to autosaves assigned to specific authors.", + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "default": [], + "required": false } } }, @@ -2528,6 +2538,16 @@ mockedApiResponse.Schema = { ], "default": "view", "required": false + }, + "author": { + "description": "Limit result set to autosaves assigned to specific authors.", + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "default": [], + "required": false } } }, @@ -4438,6 +4458,16 @@ mockedApiResponse.Schema = { ], "default": "view", "required": false + }, + "author": { + "description": "Limit result set to autosaves assigned to specific authors.", + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "default": [], + "required": false } } }, @@ -5470,6 +5500,16 @@ mockedApiResponse.Schema = { ], "default": "view", "required": false + }, + "author": { + "description": "Limit result set to autosaves assigned to specific authors.", + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "default": [], + "required": false } } }, @@ -5854,6 +5894,16 @@ mockedApiResponse.Schema = { ], "default": "view", "required": false + }, + "author": { + "description": "Limit result set to autosaves assigned to specific authors.", + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "default": [], + "required": false } } }, @@ -6538,6 +6588,16 @@ mockedApiResponse.Schema = { ], "default": "view", "required": false + }, + "author": { + "description": "Limit result set to autosaves assigned to specific authors.", + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "default": [], + "required": false } } }, @@ -7955,6 +8015,16 @@ mockedApiResponse.Schema = { ], "default": "view", "required": false + }, + "author": { + "description": "Limit result set to autosaves assigned to specific authors.", + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "default": [], + "required": false } } },