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:
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user