Make WordPress Core

Changeset 62952


Ignore:
Timestamp:
07/31/2026 06:08:13 AM (2 months ago)
Author:
westonruter
Message:

REST API: Restore the global post after preparing a revision.

WP_REST_Revisions_Controller::prepare_item_for_response() set the global $post to the revision being prepared and called setup_postdata(), but never restored the previous value, so the change persisted for the remainder of the request. WP_REST_Autosaves_Controller delegates to this method and the block editor preloads the autosaves endpoint on every load, so a post with a pending autosave could leave the global $post pointing at the autosave revision while edit-form-blocks.php built the editor bootstrap, initializing the editor with the wrong post and rewriting the URL to it.

The previous global $post is now captured before setup_postdata() and restored on every return path, including the early return for HEAD requests.

Developed in ​https://github.com/WordPress/wordpress-develop/pull/12248.
Follow-up to r40601, r59899.

Props micahele, gusgomezpg, westonruter, wildworks, dhrupo.
See #40626, #43502.
Fixes #65495.

Location:
trunk
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php

    r62928 r62952  
    596596         * @since 4.7.0
    597597         * @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support.
    598          *
    599          * @global WP_Post $post Global post object.
     598         * @since 7.1.0 The global post is now restored to its previous value before returning.
     599         *
     600         * @global WP_Post|null $post Global post object.
    600601         *
    601602         * @param WP_Post         $item    Post revision object.
    … …  
    604605         */
    605606        public function prepare_item_for_response( $item, $request ) {
     607                /*
     608                 * Save the previous global post so it can be restored before returning.
     609                 * Preparing the revision sets up the global post and post data, which
     610                 * must not leak into the rest of the request (e.g. the autosaves endpoint
     611                 * is preloaded in the block editor, where a leaked global post can cause
     612                 * the editor to be initialized with the wrong post).
     613                 *
     614                 * Note that $post is intentionally not declared as a global here. It must
     615                 * remain local to this method so that a filter which reassigns the global
     616                 * post while the response is being prepared (for example on 'the_content')
     617                 * cannot change which post the remaining fields are read from.
     618                 */
     619                $previous_post = isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof WP_Post ? $GLOBALS['post'] : null;
     620
    606621                // Restores the more descriptive, specific name for use within this method.
    607622                $post = $item;
    … …  
    614629                if ( $request->is_method( 'HEAD' ) ) {
    615630                        /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php */
    616                         return apply_filters( 'rest_prepare_revision', new WP_REST_Response( array() ), $post, $request );
     631                        $response = apply_filters( 'rest_prepare_revision', new WP_REST_Response( array() ), $post, $request );
     632
     633                        $this->restore_post_data( $previous_post );
     634
     635                        return $response;
    617636                }
    618637
    … …  
    718737                 * @param WP_REST_Request  $request  Request used to generate the response.
    719738                 */
    720                 return apply_filters( 'rest_prepare_revision', $response, $post, $request );
     739                $response = apply_filters( 'rest_prepare_revision', $response, $post, $request );
     740
     741                $this->restore_post_data( $previous_post );
     742
     743                return $response;
     744        }
     745
     746        /**
     747         * Restores the global post to its previous value after preparing a revision.
     748         *
     749         * Preparing a revision overwrites the global post and post data via
     750         * setup_postdata(). This restores the global post that was in place
     751         * beforehand so the change does not leak into the rest of the request.
     752         *
     753         * Only the global post is guaranteed to be restored. When there was no
     754         * previous global post and the main query has no post either, which is the
     755         * usual state during a REST request, wp_reset_postdata() has nothing to
     756         * restore from, so the remaining globals set by setup_postdata() (such as
     757         * $id, $authordata and $pages) are left describing the revision. Clearing
     758         * those would mean unsetting each one by hand, which is beyond what is
     759         * needed to keep the global post from leaking.
     760         *
     761         * @since 7.1.0
     762         *
     763         * @param WP_Post|null $previous_post The global post to restore, or null if there was none.
     764         */
     765        private function restore_post_data( ?WP_Post $previous_post ): void {
     766                if ( $previous_post ) {
     767                        $GLOBALS['post'] = $previous_post;
     768                        setup_postdata( $previous_post );
     769                        return;
     770                }
     771
     772                /*
     773                 * There was no global post to restore, so clear the revision's post data.
     774                 * This runs before clearing the global post because wp_reset_postdata()
     775                 * repopulates it from the main query whenever that query has a post. Note
     776                 * that it is a no-op when the main query has no post, in which case only
     777                 * the global post below is cleared.
     778                 */
     779                wp_reset_postdata();
     780
     781                /*
     782                 * Assigned rather than unset so that any `global $post` binding made before
     783                 * this request keeps pointing at the global. Unsetting removes the entry from
     784                 * the symbol table, which detaches those bindings, and a later write through
     785                 * one of them would no longer be visible to get_post().
     786                 */
     787                $GLOBALS['post'] = null;
    721788        }
    722789
  • trunk/tests/phpunit/tests/rest-api/rest-autosaves-controller.php

    r62334 r62952  
    1010 */
    1111class WP_Test_REST_Autosaves_Controller extends WP_Test_REST_Post_Type_Controller_Testcase {
    12         protected static $post_id;
    13         protected static $page_id;
    14         protected static $draft_page_id;
    15 
    16         protected static $autosave_post_id;
    17         protected static $autosave_page_id;
    18 
    19         protected static $editor_id;
    20         protected static $contributor_id;
    21 
    22         protected static $parent_page_id;
    23         protected static $child_page_id;
    24         protected static $child_draft_page_id;
    25 
    26         private $post_autosave;
     12        protected static int $post_id;
     13        protected static int $page_id;
     14        protected static int $draft_page_id;
     15
     16        protected static int $autosave_post_id;
     17        protected static int $autosave_page_id;
     18
     19        protected static int $editor_id;
     20        protected static int $contributor_id;
     21
     22        protected static int $parent_page_id;
     23        protected static int $child_page_id;
     24        protected static int $child_draft_page_id;
     25
     26        private WP_Post $post_autosave;
    2727
    2828        protected function set_post_data( $args = array() ) {
    … …  
    732732        }
    733733
    734         public function test_get_item_sets_up_postdata() {
    735                 wp_set_current_user( self::$editor_id );
    736                 $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id );
    737                 rest_get_server()->dispatch( $request );
    738 
    739                 $post           = get_post();
    740                 $parent_post_id = wp_is_post_revision( $post->ID );
    741 
    742                 $this->assertSame( $post->ID, self::$autosave_post_id );
    743                 $this->assertSame( $parent_post_id, self::$post_id );
     734        /**
     735         * The autosave's postdata should be set up while preparing the response,
     736         * so rendered fields reflect the autosave, without leaking into the global
     737         * post after the request completes.
     738         *
     739         * @ticket 65495
     740         *
     741         * @global int|null $id ID from the set up global post data.
     742         *
     743         * @covers WP_REST_Autosaves_Controller::prepare_item_for_response
     744         */
     745        public function test_get_item_sets_up_postdata_without_leaking_global_post() {
     746                global $id;
     747
     748                // Populate the global $wp_query with the post and set it up.
     749                wp_set_current_user( self::$editor_id );
     750                query_posts( array( 'p' => self::$post_id ) );
     751                the_post();
     752
     753                // Assert initial state.
     754                $post = get_post();
     755                $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' );
     756                $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' );
     757                $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' );
     758
     759                // Make the request to get the autosave.
     760                $request  = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id );
     761                $response = rest_get_server()->dispatch( $request );
     762                $this->assertSame( 200, $response->get_status() );
     763                $data = $response->get_data();
     764                $this->assertIsArray( $data );
     765                $this->assertArrayHasKey( 'title', $data );
     766                $this->assertIsArray( $data['title'] );
     767                $this->assertSame( get_the_title( self::$autosave_post_id ), $data['title']['rendered'], 'Expected the rendered title to reflect the autosave, proving postdata was set up during preparation.' );
     768
     769                // The global post is restored to the post that was set before the request.
     770                $post = get_post();
     771                $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' );
     772                $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' );
     773                $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' );
    744774        }
    745775
  • trunk/tests/phpunit/tests/rest-api/rest-revisions-controller.php

    r59985 r62952  
    99 */
    1010class WP_Test_REST_Revisions_Controller extends WP_Test_REST_Controller_Testcase {
    11         protected static $post_id;
    12         protected static $post_id_2;
    13         protected static $page_id;
    14 
    15         protected static $editor_id;
    16         protected static $contributor_id;
    17 
    18         private $total_revisions;
     11        protected static int $post_id;
     12        protected static int $post_id_2;
     13        protected static int $page_id;
     14
     15        protected static int $editor_id;
     16        protected static int $contributor_id;
     17
     18        private int $total_revisions;
     19
    1920        private $revisions;
    20         private $revision_1;
    21         private $revision_id1;
    22         private $revision_2;
    23         private $revision_id2;
    24         private $revision_3;
    25         private $revision_id3;
    26         private $revision_2_1_id;
     21
     22        private WP_Post $revision_1;
     23        private int $revision_id1;
     24        private WP_Post $revision_2;
     25        private int $revision_id2;
     26        private WP_Post $revision_3;
     27        private int $revision_id3;
     28        private int $revision_2_1_id;
    2729
    2830        public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
    … …  
    265267                $this->assertSameSets( $fields, array_keys( $data ) );
    266268                $this->assertSame( self::$editor_id, $data['author'] );
     269        }
     270
     271        /**
     272         * Preparing a revision must not leak the revision into the global post.
     273         *
     274         * @ticket 65495
     275         *
     276         * @global int|null $id ID from the set up global post data.
     277         *
     278         * @covers WP_REST_Revisions_Controller::prepare_item_for_response
     279         */
     280        public function test_get_items_restores_global_post() {
     281                global $id;
     282
     283                // Populate the global $wp_query with the post and set it up.
     284                wp_set_current_user( self::$editor_id );
     285                query_posts( array( 'p' => self::$post_id ) );
     286                the_post();
     287
     288                // Assert initial state.
     289                $post = get_post();
     290                $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' );
     291                $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' );
     292                $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' );
     293
     294                // Capture the arguments the rest_prepare_revision filter receives.
     295                $mock = new MockAction();
     296                add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 );
     297
     298                // Make the request to get revisions.
     299                $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' );
     300                $request->set_param( 'context', 'edit' );
     301                $response = rest_get_server()->dispatch( $request );
     302
     303                $this->assertSame( 200, $response->get_status() );
     304
     305                // The filter is passed each revision, not the global post restored afterwards.
     306                $this->check_rest_prepare_revision_filter_args(
     307                        $mock,
     308                        array( $this->revision_id3, $this->revision_id2, $this->revision_id1 ),
     309                        $request
     310                );
     311
     312                // The global post is restored to the post that was set before the request.
     313                $post = get_post();
     314                $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' );
     315                $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' );
     316                $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' );
     317        }
     318
     319        /**
     320         * Preparing a revision for a HEAD request must also restore the global post.
     321         *
     322         * The collection endpoint short-circuits before preparing items for HEAD
     323         * requests, so the single revision endpoint is used to reach the HEAD
     324         * branch of prepare_item_for_response().
     325         *
     326         * @ticket 65495
     327         *
     328         * @global int|null $id ID from the set up global post data.
     329         *
     330         * @covers WP_REST_Revisions_Controller::prepare_item_for_response
     331         */
     332        public function test_get_item_head_request_restores_global_post() {
     333                global $id;
     334
     335                // Populate the global $wp_query with the post and set it up.
     336                wp_set_current_user( self::$editor_id );
     337                query_posts( array( 'p' => self::$post_id ) );
     338                the_post();
     339
     340                // Assert initial state.
     341                $post = get_post();
     342                $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' );
     343                $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' );
     344                $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' );
     345
     346                // Capture the arguments the rest_prepare_revision filter receives.
     347                $mock = new MockAction();
     348                add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 );
     349
     350                // Make the HEAD request to get a revision.
     351                $request = new WP_REST_Request( 'HEAD', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 );
     352                $request->set_param( 'context', 'edit' );
     353                $response = rest_get_server()->dispatch( $request );
     354                $this->assertSame( 200, $response->get_status() );
     355
     356                // The filter is passed the revision, not the global post restored afterwards.
     357                $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request );
     358
     359                // The global post is restored to the post that was set before the request.
     360                $post = get_post();
     361                $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' );
     362                $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' );
     363                $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' );
     364        }
     365
     366        /**
     367         * When there is no global post before the request, none should be set afterwards.
     368         *
     369         * @ticket 65495
     370         *
     371         * @global WP_Query|null $wp_query The global WP_Query.
     372         *
     373         * @covers WP_REST_Revisions_Controller::prepare_item_for_response
     374         */
     375        public function test_get_items_without_global_post_leaves_it_unset() {
     376                global $wp_query;
     377
     378                // Leave the global $wp_query without a post, so there is no post data to restore.
     379                wp_set_current_user( self::$editor_id );
     380                $this->assertInstanceOf( WP_Query::class, $wp_query, 'The WP_Query global must be set for wp_reset_postdata() to have anything to restore from.' );
     381                $this->assertNull( get_post(), 'The global post should not have been initially set.' );
     382
     383                // Capture the arguments the rest_prepare_revision filter receives.
     384                $mock = new MockAction();
     385                add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 );
     386
     387                // Make the request to get a revision.
     388                $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' );
     389                $request->set_param( 'context', 'edit' );
     390                $response = rest_get_server()->dispatch( $request );
     391                $this->assertSame( 200, $response->get_status() );
     392
     393                // The filter is passed each revision, even though there is no global post to restore.
     394                $this->check_rest_prepare_revision_filter_args(
     395                        $mock,
     396                        array( $this->revision_id3, $this->revision_id2, $this->revision_id1 ),
     397                        $request
     398                );
     399
     400                /*
     401                 * Note: At this point, the global $id is still populated because there was no $wp_query->post to begin with,
     402                 * so WP_Query::reset_postdata() has nothing to set it to. It does not null out any globals when there is no post.
     403                 */
     404                $this->assertNull( get_post(), 'The global post should not be set when there was none before the request.' );
     405        }
     406
     407        /**
     408         * A main query with a post must not cause a global post to be set where there was none.
     409         *
     410         * The restore calls wp_reset_postdata() to clear the revision's post data, and that
     411         * repopulates the global post from the main query. The global post must be unset
     412         * afterwards so the request does not introduce one that was not there before.
     413         *
     414         * @ticket 65495
     415         *
     416         * @global WP_Query|null $wp_query The global WP_Query.
     417         * @global int|null      $id       ID from the set up global post data.
     418         *
     419         * @covers WP_REST_Revisions_Controller::prepare_item_for_response
     420         */
     421        public function test_get_items_without_global_post_leaves_it_unset_when_main_query_has_post() {
     422                global $wp_query, $id;
     423
     424                /*
     425                 * Populate the main query with the post, but do not run the loop, so the main
     426                 * query has a post to restore from while the global post remains unset.
     427                 */
     428                wp_set_current_user( self::$editor_id );
     429                query_posts( array( 'p' => self::$post_id ) );
     430
     431                // Assert initial state.
     432                $this->assertInstanceOf( WP_Query::class, $wp_query, 'The WP_Query global must be set for wp_reset_postdata() to have anything to restore from.' );
     433                $this->assertInstanceOf( WP_Post::class, $wp_query->post, 'The main query should have a post for wp_reset_postdata() to restore from.' );
     434                $this->assertSame( self::$post_id, $wp_query->post->ID, 'The main query should have the parent post before the request.' );
     435                $this->assertNull( get_post(), 'The global post should not have been initially set.' );
     436
     437                // Make the request to get revisions.
     438                $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' );
     439                $request->set_param( 'context', 'edit' );
     440                $response = rest_get_server()->dispatch( $request );
     441                $this->assertSame( 200, $response->get_status() );
     442
     443                $this->assertNull( get_post(), 'The global post should not be set when there was none before the request, even though the main query has a post.' );
     444                $this->assertSame( self::$post_id, $id, 'The remaining post data should be reset to the main query post rather than left on the revision.' );
     445        }
     446
     447        /**
     448         * A filter that reassigns the global post must not change the revision being prepared.
     449         *
     450         * The revision is held in a method-local variable rather than the global post, so a
     451         * filter which swaps the global out mid-preparation (as a plugin running a secondary
     452         * loop on 'the_content' may do) cannot retarget the fields prepared after it.
     453         *
     454         * @ticket 65495
     455         *
     456         * @covers WP_REST_Revisions_Controller::prepare_item_for_response
     457         */
     458        public function test_prepare_item_for_response_is_unaffected_by_a_filter_reassigning_the_global_post() {
     459                wp_set_current_user( self::$editor_id );
     460
     461                $decoy_post = get_post( self::$post_id_2 );
     462                $this->assertInstanceOf( WP_Post::class, $decoy_post );
     463                $this->assertNotSame( $this->revision_1->post_excerpt, $decoy_post->post_excerpt, 'The decoy post must have a different excerpt for this test to be meaningful.' );
     464
     465                // Simulate a plugin that leaves a different post in the global while filtering the content.
     466                add_filter(
     467                        'the_content',
     468                        static function ( $content ) use ( $decoy_post ) {
     469                                $GLOBALS['post'] = $decoy_post;
     470                                return $content;
     471                        }
     472                );
     473
     474                // Capture the post the rest_prepare_revision filter receives.
     475                $mock = new MockAction();
     476                add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 );
     477
     478                $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 );
     479                $request->set_param( 'context', 'edit' );
     480                $response = rest_get_server()->dispatch( $request );
     481
     482                $this->assertSame( 200, $response->get_status() );
     483
     484                // Fields prepared after 'the_content' still come from the revision.
     485                $data = $response->get_data();
     486                $this->assertIsArray( $data );
     487                $this->assertArrayHasKey( 'excerpt', $data );
     488                $this->assertIsArray( $data['excerpt'] );
     489                $this->assertSame( $this->revision_id1, $data['id'], 'The prepared id should be the revision, not the post left in the global by the filter.' );
     490                $this->assertSame( $this->revision_1->post_excerpt, $data['excerpt']['raw'], 'The prepared excerpt should come from the revision, not the post left in the global by the filter.' );
     491
     492                // The filter is still passed the revision.
     493                $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request );
     494        }
     495
     496        /**
     497         * Clearing the global post must not detach an existing `global $post` binding.
     498         *
     499         * `global $post` binds a caller to the global symbol table entry. Unsetting that
     500         * entry detaches the binding, so a caller which sets the post after the request
     501         * would be writing somewhere get_post() can no longer see. Note that the caller's
     502         * own `global $post` is what creates the entry as null, which is why there is no
     503         * previous global post to restore here.
     504         *
     505         * @ticket 65495
     506         *
     507         * @global WP_Post|null $post Global post object.
     508         *
     509         * @covers WP_REST_Revisions_Controller::prepare_item_for_response
     510         */
     511        public function test_prepare_item_for_response_does_not_detach_an_existing_global_post_binding() {
     512                // Bind to the global post the way a caller does before dispatching a request.
     513                global $post;
     514
     515                wp_set_current_user( self::$editor_id );
     516                $this->assertNull( $post, 'The global post should not be set before the request.' );
     517
     518                $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 );
     519                $request->set_param( 'context', 'edit' );
     520                $response = rest_get_server()->dispatch( $request );
     521                $this->assertSame( 200, $response->get_status() );
     522
     523                // The caller sets the global post, expecting template tags to pick it up.
     524                $post = get_post( self::$post_id );
     525
     526                $global_post = get_post();
     527                $this->assertInstanceOf( WP_Post::class, $global_post, 'get_post() should see the post set through the binding after the request.' );
     528                $this->assertSame( self::$post_id, $global_post->ID, 'get_post() should return the post the caller set, not a stale or detached value.' );
    267529        }
    268530
    … …  
    602864        }
    603865
     866        /**
     867         * Checks the arguments the rest_prepare_revision filter received.
     868         *
     869         * The filter must be passed the revision that was prepared. Restoring the
     870         * global post afterwards must not replace it with the previous global post.
     871         *
     872         * @param MockAction      $mock         Mock registered on the rest_prepare_revision filter.
     873         * @param int[]           $revision_ids Expected revision IDs, in the order the filter is expected to fire.
     874         * @param WP_REST_Request $request      Request the revisions were prepared for.
     875         */
     876        private function check_rest_prepare_revision_filter_args( MockAction $mock, array $revision_ids, WP_REST_Request $request ): void {
     877                $filter_args = $mock->get_args();
     878
     879                $this->assertCount( count( $revision_ids ), $filter_args, 'The rest_prepare_revision filter should fire once per prepared revision.' );
     880
     881                foreach ( $filter_args as $index => $args ) {
     882                        $call = 'Filter call ' . $index . ': ';
     883
     884                        $this->assertCount( 3, $args, $call . 'the filter should receive three arguments.' );
     885                        $this->assertInstanceOf( WP_REST_Response::class, $args[0], $call . 'the first argument should be the response.' );
     886                        $this->assertInstanceOf( WP_Post::class, $args[1], $call . 'the second argument should be a post object.' );
     887                        $this->assertSame( 'revision', $args[1]->post_type, $call . 'the second argument should be a revision, not the restored global post.' );
     888                        $this->assertSame( $revision_ids[ $index ], $args[1]->ID, $call . 'the second argument should be the revision that was prepared.' );
     889                        $this->assertSame( $request, $args[2], $call . 'the third argument should be the request.' );
     890                }
     891        }
     892
    604893        protected function check_get_revision_response( $response, $revision ) {
    605894                if ( $response instanceof WP_REST_Response ) {
    … …  
    640929        }
    641930
    642         public function test_get_item_sets_up_postdata() {
    643                 wp_set_current_user( self::$editor_id );
    644                 $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 );
    645                 rest_get_server()->dispatch( $request );
    646 
    647                 $post           = get_post();
    648                 $parent_post_id = wp_is_post_revision( $post->ID );
    649 
    650                 $this->assertSame( $post->ID, $this->revision_id1 );
    651                 $this->assertSame( $parent_post_id, self::$post_id );
     931        /**
     932         * The revision's postdata should be set up while preparing the response,
     933         * so rendered fields reflect the revision, without leaking into the global
     934         * post after the request completes.
     935         *
     936         * @ticket 65495
     937         *
     938         * @global int|null $id ID from the set up global post data.
     939         *
     940         * @covers WP_REST_Revisions_Controller::prepare_item_for_response
     941         */
     942        public function test_get_item_sets_up_postdata_without_leaking_global_post() {
     943                global $id;
     944
     945                // Populate the global $wp_query with the post and set it up.
     946                wp_set_current_user( self::$editor_id );
     947                query_posts( array( 'p' => self::$post_id ) );
     948                the_post();
     949
     950                // Assert initial state.
     951                $post = get_post();
     952                $this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' );
     953                $this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' );
     954                $this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' );
     955
     956                // Capture the arguments the rest_prepare_revision filter receives.
     957                $mock = new MockAction();
     958                add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 );
     959
     960                // Make the request to get a revision.
     961                $request  = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 );
     962                $response = rest_get_server()->dispatch( $request );
     963                $this->assertSame( 200, $response->get_status() );
     964
     965                // The filter is passed the revision, not the global post restored afterwards.
     966                $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request );
     967
     968                $data = $response->get_data();
     969                $this->assertIsArray( $data );
     970                $this->assertArrayHasKey( 'title', $data );
     971                $this->assertIsArray( $data['title'] );
     972                $this->assertSame( get_the_title( $this->revision_id1 ), $data['title']['rendered'], 'Expected the rendered title to reflect the revision, proving postdata was set up during preparation.' );
     973
     974                // The global post is restored to the post that was set before the request.
     975                $post = get_post();
     976                $this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' );
     977                $this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' );
     978                $this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' );
    652979        }
    653980
Note: See TracChangeset for help on using the changeset viewer.