Skip to content
Merged
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
45 changes: 45 additions & 0 deletions src/posts-list/class-bulk-edit.php
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,29 @@ public static function handle_bulk_generate_action( $redirect, $doaction, $objec
$redirect
);

// Only operate on posts the current user is actually allowed to edit. Core
// routes custom bulk actions through this filter after only a coarse
// edit_posts check, so without this per-post guard a crafted request could
// force (billable) audio generation on another author's posts. Mirrors the
// capability filter in save_bulk_edit().
$object_ids = array_values(
array_filter(
$object_ids,
static fn( $post_id ): bool => current_user_can( 'edit_post', $post_id )
)
);

// A capable user may still have selected only posts they cannot edit; treat
// that as a clean no-op reporting a zero count.
if ( empty( $object_ids ) ) {
$redirect = add_query_arg( 'beyondwords_bulk_generated', 0, $redirect );
$redirect = add_query_arg( 'beyondwords_bulk_failed', 0, $redirect );

$nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );

return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
}

sort( $object_ids );

$generated = 0;
Expand Down Expand Up @@ -315,6 +338,28 @@ public static function handle_bulk_delete_action( $redirect, $doaction, $object_
$redirect
);

// Only operate on posts the current user is actually allowed to edit. Core
// routes custom bulk actions through this filter after only a coarse
// edit_posts check, so without this per-post guard a crafted request could
// remotely delete audio and wipe plugin meta on another author's posts.
// Mirrors the capability filter in save_bulk_edit().
$object_ids = array_values(
array_filter(
$object_ids,
static fn( $post_id ): bool => current_user_can( 'edit_post', $post_id )
)
);

// Bail before the remote batch-delete when nothing editable remains, so an
// empty selection cannot trigger an API call (or the BULK-NO-RESPONSE error).
if ( empty( $object_ids ) ) {
$redirect = add_query_arg( 'beyondwords_bulk_deleted', 0, $redirect );

$nonce = wp_create_nonce( 'beyondwords_bulk_edit_result' );

return add_query_arg( 'beyondwords_bulk_edit_result_nonce', $nonce, $redirect );
}

sort( $object_ids );

try {
Expand Down
252 changes: 252 additions & 0 deletions tests/phpunit/posts-list/test-bulk-edit.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ public function tearDown(): void
// Your tear down methods here.
unset($_POST, $_REQUEST);

// The redirect handlers gate on current_user_can( 'edit_post', ... ), so
// several tests log a user in. Reset it so state cannot leak into sibling
// test classes.
wp_set_current_user(0);

// Then...
parent::tearDown();
}
Expand Down Expand Up @@ -149,6 +154,10 @@ public function bulk_actions_edit()
*/
public function handle_bulk_generate_action()
{
// The handler now filters $object_ids by current_user_can( 'edit_post' ),
// so run as an administrator who can edit every selected post.
wp_set_current_user(self::factory()->user->create(['role' => 'administrator']));

// Set up API credentials for integration test
update_option('beyondwords_api_key', BEYONDWORDS_TESTS_API_KEY);
update_option('beyondwords_project_id', BEYONDWORDS_TESTS_PROJECT_ID);
Expand Down Expand Up @@ -232,6 +241,10 @@ public function handle_bulk_generate_action()
*/
public function handle_bulk_generate_action_with_no_api_credentials()
{
// The handler now filters $object_ids by current_user_can( 'edit_post' ),
// so run as an administrator who can edit every selected post.
wp_set_current_user(self::factory()->user->create(['role' => 'administrator']));

// Test error handling when API credentials are missing
// This tests the failure path where posts cannot be generated

Expand Down Expand Up @@ -277,4 +290,243 @@ public function handle_bulk_generate_action_with_no_api_credentials()
wp_delete_post($postId, true);
}
}

/**
* A user with `edit_posts` may still lack `edit_post` for an individual post
* they do not own. Core routes custom bulk actions through this filter after
* only the coarse edit_posts gate, so the redirect-based generate handler must
* itself drop posts the user cannot edit — only the editable post is flagged
* for generation and counted. Mirrors the AJAX coverage in test-bulk-edit-ajax.php.
*
* @test
*
* @backupGlobals disabled
*/
public function handle_bulk_generate_action_skips_posts_the_user_cannot_edit()
{
$authorId = self::factory()->user->create(['role' => 'author']);
$otherAuthorId = self::factory()->user->create(['role' => 'author']);

wp_set_current_user($authorId);

$ownPostId = self::factory()->post->create([
'post_author' => $authorId,
'post_status' => 'publish',
'post_title' => 'BulkEditTest::generate-skip::own',
]);

$otherPostId = self::factory()->post->create([
'post_author' => $otherAuthorId,
'post_status' => 'publish',
'post_title' => 'BulkEditTest::generate-skip::other',
]);

// Intercept the create-audio request with a benign success so generation
// completes hermetically, without a real network call.
$mockHttp = function () {
return [
'response' => ['code' => 200, 'message' => 'OK'],
'body' => '{}',
'headers' => [],
'cookies' => [],
];
};
add_filter('pre_http_request', $mockHttp, 10, 3);

$redirect = BulkEdit::handle_bulk_generate_action(
'https://example.com/wp-admin/edit.php',
'beyondwords_generate_audio',
[$ownPostId, $otherPostId]
);

remove_filter('pre_http_request', $mockHttp, 10);

// Only the editable post is flagged for generation.
$this->assertSame('1', get_post_meta($ownPostId, 'beyondwords_generate_audio', true));
$this->assertEmpty(get_post_meta($otherPostId, 'beyondwords_generate_audio', true));

// generated + failed counts only the one editable post.
$query = parse_url($redirect, PHP_URL_QUERY);
parse_str($query, $args);
$this->assertEquals(
1,
(int)$args['beyondwords_bulk_generated'] + (int)$args['beyondwords_bulk_failed']
);

wp_delete_post($ownPostId, true);
wp_delete_post($otherPostId, true);
}

/**
* When the per-post filter removes every selected post, the generate handler
* must be a clean no-op: a zero count, no post mutated, and no API request.
*
* @test
*
* @backupGlobals disabled
*/
public function handle_bulk_generate_action_is_a_noop_when_no_editable_posts_remain()
{
$authorId = self::factory()->user->create(['role' => 'author']);
$otherAuthorId = self::factory()->user->create(['role' => 'author']);

wp_set_current_user($authorId);

$otherPostId = self::factory()->post->create([
'post_author' => $otherAuthorId,
'post_status' => 'publish',
'post_title' => 'BulkEditTest::generate-noop::other',
]);

$httpAttempted = false;
$filter = function ($preempt) use (&$httpAttempted) {
$httpAttempted = true;
return new WP_Error('blocked', 'No HTTP expected in this no-op test.');
};
add_filter('pre_http_request', $filter, 10, 1);

$redirect = BulkEdit::handle_bulk_generate_action(
'https://example.com/wp-admin/edit.php',
'beyondwords_generate_audio',
[$otherPostId]
);

remove_filter('pre_http_request', $filter, 10);

$query = parse_url($redirect, PHP_URL_QUERY);
parse_str($query, $args);

$this->assertEquals(0, $args['beyondwords_bulk_generated']);
$this->assertEquals(0, $args['beyondwords_bulk_failed']);
$this->assertArrayHasKey('beyondwords_bulk_edit_result_nonce', $args);
$this->assertFalse($httpAttempted, 'No API request should be made when no editable posts remain.');
$this->assertEmpty(get_post_meta($otherPostId, 'beyondwords_generate_audio', true));

wp_delete_post($otherPostId, true);
}

/**
* On the delete branch the per-post `edit_post` filter must drop posts the
* user cannot edit, so the API batch-delete request only ever carries the
* editable post's content — and only that post's meta is cleared.
*
* @test
*
* @backupGlobals disabled
*/
public function handle_bulk_delete_action_skips_posts_the_user_cannot_edit()
{
$authorId = self::factory()->user->create(['role' => 'author']);
$otherAuthorId = self::factory()->user->create(['role' => 'author']);

wp_set_current_user($authorId);

$ownPostId = self::factory()->post->create([
'post_author' => $authorId,
'post_status' => 'publish',
'post_title' => 'BulkEditTest::delete-skip::own',
]);
update_post_meta($ownPostId, 'beyondwords_project_id', 12345);
update_post_meta($ownPostId, 'beyondwords_content_id', 'own-content-aaa');

$otherPostId = self::factory()->post->create([
'post_author' => $otherAuthorId,
'post_status' => 'publish',
'post_title' => 'BulkEditTest::delete-skip::other',
]);
update_post_meta($otherPostId, 'beyondwords_project_id', 12345);
update_post_meta($otherPostId, 'beyondwords_content_id', 'other-content-bbb');

$requests = [];
$mockHttp = function ($preempt, $args, $url) use (&$requests) {
if (str_contains($url, '/content/batch_delete')) {
$requests[] = $args['body'] ?? '';
return [
'response' => ['code' => 200, 'message' => 'OK'],
'body' => '{}',
'headers' => [],
'cookies' => [],
];
}

return $preempt;
};
add_filter('pre_http_request', $mockHttp, 10, 3);

$redirect = BulkEdit::handle_bulk_delete_action(
'https://example.com/wp-admin/edit.php',
'beyondwords_delete_audio',
[$ownPostId, $otherPostId]
);

remove_filter('pre_http_request', $mockHttp, 10);

// Exactly one batch-delete request, carrying only the editable post's content.
$this->assertCount(1, $requests);
$this->assertStringContainsString('own-content-aaa', $requests[0]);
$this->assertStringNotContainsString('other-content-bbb', $requests[0]);

// Only the editable post's meta is cleared; the other post is untouched.
$this->assertSame('', get_post_meta($ownPostId, 'beyondwords_content_id', true));
$this->assertSame('other-content-bbb', get_post_meta($otherPostId, 'beyondwords_content_id', true));

// The redirect reports exactly one deleted post.
$query = parse_url($redirect, PHP_URL_QUERY);
parse_str($query, $args);
$this->assertEquals(1, $args['beyondwords_bulk_deleted']);

wp_delete_post($ownPostId, true);
wp_delete_post($otherPostId, true);
}

/**
* When the per-post filter removes every selected post, the delete handler
* must be a clean no-op: a zero count, no API request, and — critically — no
* BULK-NO-RESPONSE error from handing an empty batch to the API.
*
* @test
*
* @backupGlobals disabled
*/
public function handle_bulk_delete_action_is_a_noop_when_no_editable_posts_remain()
{
$authorId = self::factory()->user->create(['role' => 'author']);
$otherAuthorId = self::factory()->user->create(['role' => 'author']);

wp_set_current_user($authorId);

$otherPostId = self::factory()->post->create([
'post_author' => $otherAuthorId,
'post_status' => 'publish',
'post_title' => 'BulkEditTest::delete-noop::other',
]);
update_post_meta($otherPostId, 'beyondwords_project_id', 12345);
update_post_meta($otherPostId, 'beyondwords_content_id', 'noop-content-id');

$httpAttempted = false;
$filter = function ($preempt) use (&$httpAttempted) {
$httpAttempted = true;
return new WP_Error('blocked', 'No HTTP expected in this no-op test.');
};
add_filter('pre_http_request', $filter, 10, 1);

$redirect = BulkEdit::handle_bulk_delete_action(
'https://example.com/wp-admin/edit.php',
'beyondwords_delete_audio',
[$otherPostId]
);

remove_filter('pre_http_request', $filter, 10);

$query = parse_url($redirect, PHP_URL_QUERY);
parse_str($query, $args);

// Zero deleted, no error surfaced, and no API request attempted.
$this->assertEquals(0, $args['beyondwords_bulk_deleted']);
$this->assertArrayNotHasKey('beyondwords_bulk_error', $args);
$this->assertFalse($httpAttempted, 'No API delete should be attempted when no editable posts remain.');
$this->assertSame('noop-content-id', get_post_meta($otherPostId, 'beyondwords_content_id', true));

wp_delete_post($otherPostId, true);
}
}
Loading