fix: bounded usage scan and count-only unreadable posts in global edits (L1, L4)
GlobalUsages runs one query per request (ID, status, type – no content), checks read_post/edit_post in chunks of 200 with a primed and released post cache, lists at most 50 readable posts and refuses a confirmed global edit above 2000 live references (unsupported, edit in the Divi builder). Posts the caller may not read appear only as hidden_count / blocked_hidden_count; blocked_ids names readable posts only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,19 +50,16 @@ final class GlobalEditsTest extends TestCase
|
||||
Functions\when('_prime_post_caches')->justReturn(null);
|
||||
}
|
||||
|
||||
/** The live posts ($this->live; 1053 published, the others private) and draft 1070. */
|
||||
private function usages(): GlobalUsages
|
||||
{
|
||||
$usages = Mockery::mock(GlobalUsages::class);
|
||||
$usages->allows('of')->andReturn([
|
||||
'items' => [
|
||||
['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'],
|
||||
['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'],
|
||||
],
|
||||
'count' => 2,
|
||||
]);
|
||||
$usages->allows('liveIds')->andReturnUsing(fn (): array => $this->live);
|
||||
|
||||
return $usages;
|
||||
return LibraryFixtures::usages(fn (): array => array_merge(
|
||||
array_map(
|
||||
static fn (int $id): array => ['id' => $id, 'status' => $id === 1053 ? 'publish' : 'private', 'type' => 'page'],
|
||||
$this->live
|
||||
),
|
||||
[['id' => 1070, 'status' => 'draft', 'type' => 'page']]
|
||||
));
|
||||
}
|
||||
|
||||
/** @param list<string> $deniedRoles */
|
||||
@@ -274,6 +271,60 @@ final class GlobalEditsTest extends TestCase
|
||||
self::assertStringContainsString('abgelehnt', $dryRun->get_error_message());
|
||||
}
|
||||
|
||||
/** Audit L4: posts the caller may not read appear only as counts – no ID, status or type. */
|
||||
public function testUnreadablePostsAppearOnlyAsCounts(): void
|
||||
{
|
||||
LibraryFixtures::given([LibraryFixtures::item(1059)]);
|
||||
$this->live = [1053, 1081];
|
||||
Functions\when('current_user_can')->alias(
|
||||
static fn (string $cap, mixed ...$args): bool => ! (in_array($cap, ['read_post', 'edit_post'], true)
|
||||
&& in_array((int) ($args[0] ?? 0), [1070, 1081], true))
|
||||
);
|
||||
|
||||
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
|
||||
$refused = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
|
||||
|
||||
self::assertToolError($dryRun, 'confirm_required');
|
||||
$data = $dryRun->get_error_data();
|
||||
self::assertSame([1053], array_column($data['affected'], 'post_id'));
|
||||
self::assertSame(3, $data['affected_count']);
|
||||
self::assertSame(2, $data['hidden_count']);
|
||||
self::assertSame(1, $data['blocked_hidden_count']);
|
||||
self::assertArrayNotHasKey('blocked_ids', $data);
|
||||
self::assertStringNotContainsString('1081', $dryRun->get_error_message());
|
||||
self::assertStringNotContainsString('1070', $dryRun->get_error_message());
|
||||
self::assertStringNotContainsString('1081', (string) wp_json_encode($data));
|
||||
self::assertToolError($refused, 'forbidden');
|
||||
self::assertStringNotContainsString('1081', $refused->get_error_message());
|
||||
self::assertStringContainsString('nicht lesen darf', $refused->get_error_message());
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
/** Audit L1: above the hard cap a confirmed global edit is refused without checking each post. */
|
||||
public function testAboveTheHardCapTheConfirmIsRefused(): void
|
||||
{
|
||||
LibraryFixtures::given([LibraryFixtures::item(1059)]);
|
||||
$this->live = range(20001, 20001 + GlobalUsages::HARD_CAP);
|
||||
$edits = 0;
|
||||
Functions\when('current_user_can')->alias(static function (string $cap, mixed ...$args) use (&$edits): bool {
|
||||
$edits += $cap === 'edit_post' && ($args[0] ?? 0) !== 1059 ? 1 : 0;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
|
||||
$refused = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
|
||||
|
||||
self::assertToolError($dryRun, 'confirm_required');
|
||||
self::assertStringContainsString('mehr als 2000', $dryRun->get_error_message());
|
||||
self::assertCount(GlobalUsages::LIMIT, $dryRun->get_error_data()['affected']);
|
||||
self::assertSame(GlobalUsages::HARD_CAP + 2, $dryRun->get_error_data()['affected_count']);
|
||||
self::assertToolError($refused, 'unsupported');
|
||||
self::assertStringContainsString('Divi-Builder', $refused->get_error_data()['hint']);
|
||||
self::assertSame(0, $edits, 'no edit_post check per post above the cap');
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
public function testADryRunWithoutBlockersHasNoBlockedIds(): void
|
||||
{
|
||||
LibraryFixtures::given([LibraryFixtures::item(1059)]);
|
||||
@@ -329,12 +380,7 @@ final class GlobalEditsTest extends TestCase
|
||||
public function testDeleteNoticeUsesTheSingularForOnePage(): void
|
||||
{
|
||||
LibraryFixtures::given([LibraryFixtures::item(1058)]);
|
||||
$usages = Mockery::mock(GlobalUsages::class);
|
||||
$usages->allows('of')->andReturn([
|
||||
'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page']],
|
||||
'count' => 1,
|
||||
]);
|
||||
$usages->allows('liveIds')->andReturn([1053]);
|
||||
$usages = LibraryFixtures::usages(LibraryFixtures::rows([1053]));
|
||||
$access = LibraryFixtures::access();
|
||||
$design = DiviWriterFixture::designReader();
|
||||
$writer = new DiviWriter(
|
||||
|
||||
Reference in New Issue
Block a user