Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
24 changes: 18 additions & 6 deletions src/post-republisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array|bool|int|string|null> $data An array of slashed, sanitized, and processed post data.
* @param array<string, array|bool|int|string|null> $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<string, array|bool|int|string|null> 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'] ) ) {
Expand Down Expand Up @@ -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 );
}

Expand Down
79 changes: 76 additions & 3 deletions tests/Unit/Post_Republisher_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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.
*
Expand Down
143 changes: 141 additions & 2 deletions tests/WP/Post_Republisher_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $args Optional. Arguments for wp_insert_post.
*
* @return WP_Post The created post object.
*/
Expand Down Expand Up @@ -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<string, mixed> $postarr An array of post data to update.
*
* @return int|WP_Error The post ID on success, WP_Error on failure.
*/
Expand Down Expand Up @@ -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 = '<!-- wp:html --><section data-raw-html="preserved">'
. '<script>window.duplicatePostRawHtml = true;</script></section><!-- /wp:html -->';

$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(
'/<!-- wp:html -->.*?<!-- \/wp:html -->/s',
'<!-- wp:html --><!-- /wp:html -->',
$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.
*
Expand Down
Loading