diff --git a/composer.json b/composer.json index c9efddc23..9215ff70f 100644 --- a/composer.json +++ b/composer.json @@ -62,7 +62,7 @@ "Yoast\\WP\\Duplicate_Post\\Config\\Composer\\Actions::check_coding_standards" ], "check-cs-thresholds": [ - "@putenv YOASTCS_THRESHOLD_ERRORS=57", + "@putenv YOASTCS_THRESHOLD_ERRORS=52", "@putenv YOASTCS_THRESHOLD_WARNINGS=0", "Yoast\\WP\\Duplicate_Post\\Config\\Composer\\Actions::check_cs_thresholds" ], diff --git a/src/post-republisher.php b/src/post-republisher.php index a2b2c22b1..b46d7ccf3 100644 --- a/src/post-republisher.php +++ b/src/post-republisher.php @@ -97,10 +97,10 @@ public function register_post_statuses() { * Runs on the `wp_insert_post_data` hook in `wp_insert_post()` when * submitting the post copy. * - * @param array $data An array of slashed, sanitized, and processed post data. - * @param array $postarr An array of sanitized (and slashed) but otherwise unmodified post data. + * @param array $data An array of slashed, sanitized, and processed post data. + * @param array $postarr An array of sanitized (and slashed) but otherwise unmodified post data. * - * @return array An array of slashed, sanitized, and processed attachment post data. + * @return array An array of slashed, sanitized, and processed attachment post data. */ public function change_post_copy_status( $data, $postarr ) { if ( ! \array_key_exists( 'ID', $postarr ) || empty( $postarr['ID'] ) ) { @@ -209,9 +209,21 @@ public function republish_scheduled_post( $copy ) { return; } - \kses_remove_filters(); - $this->republish( $copy, $original_post ); - \kses_init_filters(); + // WP-Cron runs without a logged-in user, which causes capability-gated save filters + // (kses, WPBakery's wpb_remove_custom_html, and similar) to strip otherwise valid + // content. Run the republish as the copy author so those filters evaluate against + // a real user; this also triggers kses_init via the set_current_user action, which + // adds or removes kses filters based on that user's actual capabilities. + $previous_user_id = \get_current_user_id(); + \wp_set_current_user( (int) $copy->post_author ); + + try { + $this->republish( $copy, $original_post ); + } + finally { + \wp_set_current_user( $previous_user_id ); + } + $this->delete_copy( $copy->ID, $original_post->ID ); } diff --git a/tests/Unit/Post_Republisher_Test.php b/tests/Unit/Post_Republisher_Test.php index 35edf8282..d7780fec8 100644 --- a/tests/Unit/Post_Republisher_Test.php +++ b/tests/Unit/Post_Republisher_Test.php @@ -4,6 +4,7 @@ use Brain\Monkey; use Mockery; +use RuntimeException; use WP_Post; use Yoast\WP\Duplicate_Post\Permissions_Helper; use Yoast\WP\Duplicate_Post\Post_Duplicator; @@ -269,6 +270,7 @@ public function test_republish_scheduled_post() { $copy = Mockery::mock( WP_Post::class ); $copy->ID = 123; + $copy->post_author = '7'; $copy->post_status = 'future'; $this->permissions_helper @@ -284,15 +286,86 @@ public function test_republish_scheduled_post() { ->once() ->andReturn( $original ); - Monkey\Functions\expect( 'kses_remove_filters' ); - Monkey\Functions\expect( 'kses_init_filters' ); + Monkey\Functions\expect( 'get_current_user_id' ) + ->once() + ->andReturn( 0 ); + Monkey\Functions\expect( 'wp_set_current_user' ) + ->once() + ->ordered() + ->with( 7 ); + + $this->instance->expects( 'republish' )->with( $copy, $original )->once()->ordered(); + + Monkey\Functions\expect( 'wp_set_current_user' ) + ->once() + ->ordered() + ->with( 0 ); - $this->instance->expects( 'republish' )->with( $copy, $original )->once(); $this->instance->expects( 'delete_copy' )->with( $copy->ID, $original->ID )->once(); $this->instance->republish_scheduled_post( $copy ); } + /** + * Tests the republish_scheduled_post function restores the current user when republishing fails. + * + * @covers \Yoast\WP\Duplicate_Post\Post_Republisher::republish_scheduled_post + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_republish_scheduled_post_restores_current_user_when_republish_fails() { + $original = Mockery::mock( WP_Post::class ); + $original->ID = 1; + $original->post_status = 'publish'; + + $copy = Mockery::mock( WP_Post::class ); + $copy->ID = 123; + $copy->post_author = '7'; + $copy->post_status = 'future'; + + $this->permissions_helper + ->expects( 'is_rewrite_and_republish_copy' ) + ->with( $copy ) + ->once() + ->andReturnTrue(); + + $utils = Mockery::mock( 'alias:\Yoast\WP\Duplicate_Post\Utils' ); + $utils + ->expects( 'get_original' ) + ->with( $copy->ID ) + ->once() + ->andReturn( $original ); + + Monkey\Functions\expect( 'get_current_user_id' ) + ->once() + ->andReturn( 0 ); + Monkey\Functions\expect( 'wp_set_current_user' ) + ->once() + ->ordered() + ->with( 7 ); + + $this->instance + ->expects( 'republish' ) + ->with( $copy, $original ) + ->once() + ->ordered() + ->andThrow( new RuntimeException( 'Republish failed.' ) ); + + Monkey\Functions\expect( 'wp_set_current_user' ) + ->once() + ->ordered() + ->with( 0 ); + + $this->instance->expects( 'delete_copy' )->never(); + + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'Republish failed.' ); + + $this->instance->republish_scheduled_post( $copy ); + } + /** * Tests the republish_scheduled_post function when an invalid copy is passed. * diff --git a/tests/WP/Post_Republisher_Test.php b/tests/WP/Post_Republisher_Test.php index 3e0fab7f8..205759929 100644 --- a/tests/WP/Post_Republisher_Test.php +++ b/tests/WP/Post_Republisher_Test.php @@ -62,7 +62,7 @@ public function set_up() { /** * Helper method to create a published post. * - * @param array $args Optional. Arguments for wp_insert_post. + * @param array $args Optional. Arguments for wp_insert_post. * * @return WP_Post The created post object. */ @@ -99,7 +99,7 @@ private function create_rewrite_and_republish_copy( WP_Post $original ) { * This prevents the republish flow by removing the filter that changes the * copy status and by simulating a meta-box-loader request. * - * @param array $postarr An array of post data to update. + * @param array $postarr An array of post data to update. * * @return int|WP_Error The post ID on success, WP_Error on failure. */ @@ -432,6 +432,145 @@ public function test_republish_scheduled_post_republishes_copy() { $this->assertSame( '', \get_post_meta( $original_id, '_dp_has_rewrite_republish_copy', true ) ); } + /** + * Tests that republish_scheduled_post runs the republish as the copy author. + * + * This is a regression test for the scheduled-publish path stripping content + * from capability-gated shortcodes (e.g. WPBakery's `[vc_raw_html]`) because + * WP-Cron has no logged-in user and `current_user_can( 'unfiltered_html' )` + * returns false. Setting the copy author as the current user before calling + * `republish()` makes those filters evaluate against a real user. + * + * @covers ::republish_scheduled_post + * + * @return void + */ + public function test_republish_scheduled_post_runs_as_copy_author() { + $editor_id = $this->factory->user->create( [ 'role' => 'editor' ] ); + + $original = $this->create_original_post(); + $copy = $this->create_rewrite_and_republish_copy( $original ); + + $this->update_post_without_republish( + [ + 'ID' => $copy->ID, + 'post_author' => $editor_id, + 'post_title' => 'Scheduled Title', + 'post_content' => 'Scheduled content.', + ], + ); + $copy = \get_post( $copy->ID ); + + $captured_user_id = null; + $capture = static function () use ( &$captured_user_id ) { + $captured_user_id = \get_current_user_id(); + }; + \add_action( 'duplicate_post_before_republish', $capture ); + + // Simulate WP-Cron: no logged-in user. + \wp_set_current_user( 0 ); + + try { + $this->instance->republish_scheduled_post( $copy ); + } + finally { + \remove_action( 'duplicate_post_before_republish', $capture ); + } + + $this->assertSame( $editor_id, $captured_user_id ); + } + + /** + * Tests that scheduled republishing preserves capability-gated raw HTML content. + * + * @covers ::republish_scheduled_post + * @covers ::republish_post_elements + * + * @return void + */ + public function test_republish_scheduled_post_preserves_raw_html_content() { + $admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + + // On multisite, regular Administrators do not have `unfiltered_html`; only Super Admins do. + if ( \is_multisite() ) { + \grant_super_admin( $admin_id ); + } + + $raw_html_content = '
' + . '
'; + + $original = $this->create_original_post( [ 'post_author' => $admin_id ] ); + $copy = $this->create_rewrite_and_republish_copy( $original ); + + \wp_set_current_user( $admin_id ); + + $this->update_post_without_republish( + [ + 'ID' => $copy->ID, + 'post_author' => $admin_id, + 'post_content' => $raw_html_content, + ], + ); + $copy = \get_post( $copy->ID ); + + $strip_raw_html_for_users_without_capability = static function ( $data ) { + if ( \current_user_can( 'unfiltered_html' ) ) { + return $data; + } + + $data['post_content'] = \preg_replace( + '/.*?/s', + '', + $data['post_content'], + ); + + return $data; + }; + \add_filter( 'wp_insert_post_data', $strip_raw_html_for_users_without_capability, 20 ); + + // Simulate WP-Cron: no logged-in user. + \wp_set_current_user( 0 ); + + try { + $this->instance->republish_scheduled_post( $copy ); + } + finally { + \remove_filter( 'wp_insert_post_data', $strip_raw_html_for_users_without_capability, 20 ); + } + + $updated_original = \get_post( $original->ID ); + $this->assertSame( $raw_html_content, $updated_original->post_content ); + } + + /** + * Tests that republish_scheduled_post restores the previous user context after running. + * + * @covers ::republish_scheduled_post + * + * @return void + */ + public function test_republish_scheduled_post_restores_previous_user_context() { + $admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + $editor_id = $this->factory->user->create( [ 'role' => 'editor' ] ); + + $original = $this->create_original_post( [ 'post_author' => $admin_id ] ); + $copy = $this->create_rewrite_and_republish_copy( $original ); + + $this->update_post_without_republish( + [ + 'ID' => $copy->ID, + 'post_author' => $editor_id, + ], + ); + $copy = \get_post( $copy->ID ); + + \wp_set_current_user( $admin_id ); + + $this->instance->republish_scheduled_post( $copy ); + + $this->assertSame( $admin_id, \get_current_user_id() ); + } + /** * Tests republish_scheduled_post trashes copy when original is deleted. *