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:
2026-09-27 04:12:38 +02:00
co-authored by Claude Opus 5.5
parent bd28c32513
commit e8f69f67ea
12 changed files with 445 additions and 167 deletions
@@ -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(
@@ -6,60 +6,133 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class GlobalUsagesTest extends TestCase
{
public function testFindsInstancesLikeDiviAndHidesUnreadableTitles(): void
/** Audit L1: one query, ID, status and type only – never the content, no COUNT, no second scan. */
public function testFindsInstancesLikeDiviWithOneQueryWithoutContent(): void
{
$db = Mockery::mock(\wpdb::class)->makePartial();
$queries = [];
$db->allows('get_var')->andReturnUsing(static function (string $sql) use (&$queries): string {
$queries[] = $sql;
return '3';
});
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array {
$queries[] = $sql;
return [
['ID' => '1053', 'post_title' => 'Globale Instanzen', 'post_status' => 'draft', 'post_type' => 'page'],
['ID' => '1061', 'post_title' => 'Geheim', 'post_status' => 'private', 'post_type' => 'et_header_layout'],
['ID' => '1053', 'post_status' => 'draft', 'post_type' => 'page'],
['ID' => '1061', 'post_status' => 'private', 'post_type' => 'et_header_layout'],
];
});
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 1061);
$usages = (new GlobalUsages($db))->of(1058);
$rows = (new GlobalUsages($db))->rows(1058);
self::assertSame(3, $usages['count']);
self::assertSame(
[
['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page'],
['post_id' => 1061, 'title' => '(nicht lesbar)', 'status' => 'private', 'type' => 'et_header_layout'],
['id' => 1053, 'status' => 'draft', 'type' => 'page'],
['id' => 1061, 'status' => 'private', 'type' => 'et_header_layout'],
],
$usages['items']
$rows
);
self::assertCount(1, $queries);
self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]);
self::assertStringContainsString("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]);
self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[1]);
self::assertStringContainsString('LIMIT 50', $queries[1]);
}
/** Ruling P8a: every live (published, private, scheduled) post showing the entry, beyond the listing limit. */
public function testLiveIdsAreUnlimited(): void
{
$db = Mockery::mock(\wpdb::class)->makePartial();
$queries = [];
$db->allows('get_col')->andReturnUsing(static function (string $sql) use (&$queries): array {
$queries[] = $sql;
return ['1053', '1080'];
});
self::assertSame([1053, 1080], (new GlobalUsages($db))->liveIds(1059));
self::assertStringContainsString("LIKE '%\"globalModule\":\"1059\"%'", $queries[0]);
self::assertStringContainsString("post_status IN ('publish', 'private', 'future')", $queries[0]);
self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]);
self::assertStringContainsString("post_type <> 'revision'", $queries[0]);
self::assertStringNotContainsString('LIMIT', $queries[0]);
self::assertStringNotContainsString('post_content,', $queries[0]);
}
/** Audit L4: readable posts with title, unreadable ones only counted. */
public function testListsReadablePostsAndCountsTheOthers(): void
{
$usages = LibraryFixtures::usages([
['id' => 1053, 'status' => 'draft', 'type' => 'page'],
['id' => 1061, 'status' => 'private', 'type' => 'et_header_layout'],
]);
Functions\when('get_post')->alias(
static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_title' => 'Titel ' . $id])
);
Functions\when('current_user_can')->alias(
static fn (string $cap, int $id): bool => $id !== 1061
);
$report = $usages->report(1058);
self::assertSame(2, $report->count);
self::assertSame(
[['post_id' => 1053, 'title' => 'Titel 1053', 'status' => 'draft', 'type' => 'page']],
$report->items
);
self::assertSame(1, $report->hidden);
self::assertSame(1, $report->live);
self::assertSame([], $report->blocked);
self::assertSame(1, $report->blockedHidden);
self::assertSame(
['affected' => $report->items, 'affected_count' => 2, 'hidden_count' => 1, 'blocked_hidden_count' => 1],
$report->details()
);
}
/** Audit L1: rights are checked in chunks, each with its own primed cache that is dropped afterwards. */
public function testChecksLargeSetsInChunksAndReleasesTheCache(): void
{
$usages = LibraryFixtures::usages(LibraryFixtures::rows(range(1, 450)));
$primed = [];
Functions\when('_prime_post_caches')->alias(static function (array $ids) use (&$primed): void {
$primed[] = count($ids);
});
$deleted = 0;
Functions\when('wp_cache_delete')->alias(static function () use (&$deleted): bool {
++$deleted;
return true;
});
Functions\when('get_post')->justReturn(null);
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id % 100 !== 0);
$report = $usages->report(1058);
self::assertSame([GlobalUsages::CHUNK, GlobalUsages::CHUNK, 50], $primed);
self::assertSame(450, $deleted);
self::assertSame(450, $report->count);
self::assertCount(GlobalUsages::LIMIT, $report->items);
self::assertSame(4, $report->hidden);
self::assertSame(4, $report->blockedHidden);
self::assertTrue($report->blocks());
}
/** Audit L1: above the hard cap nothing is checked for edit_post, and the report blocks. */
public function testAboveTheHardCapNoPostIsCheckedForEditing(): void
{
$usages = LibraryFixtures::usages(LibraryFixtures::rows(range(1, GlobalUsages::HARD_CAP + 1)));
Functions\when('get_post')->justReturn(null);
$asked = [];
Functions\when('current_user_can')->alias(static function (string $cap) use (&$asked): bool {
$asked[$cap] = ($asked[$cap] ?? 0) + 1;
return true;
});
$report = $usages->report(1058);
self::assertTrue($report->tooMany);
self::assertTrue($report->blocks());
self::assertArrayNotHasKey('edit_post', $asked);
self::assertSame(GlobalUsages::CHECK_CAP, $asked['read_post']);
}
public function testAListingSkipsTheEditChecks(): void
{
$usages = LibraryFixtures::usages(LibraryFixtures::rows([1053]));
Functions\when('get_post')->justReturn(null);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post');
$report = $usages->report(1058, false);
self::assertFalse($report->blocks());
self::assertSame(['affected', 'affected_count'], array_keys($report->listing()));
}
}
@@ -37,11 +37,9 @@ final class LibraryListAbilityTest extends TestCase
$limit + 1
));
$access = LibraryFixtures::access($deniedRoles, true, $store);
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('of')->with(1058)->andReturn([
'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page']],
'count' => 1,
]);
$usages = LibraryFixtures::usages(
static fn (): array => [['id' => 1053, 'status' => 'draft', 'type' => 'page']]
);
return new LibraryListAbility($access, LibraryFixtures::loader($access), DiviWriterFixture::designReader(), $usages);
}