Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
316 lines
14 KiB
PHP
316 lines
14 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
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
|
||
{
|
||
/**
|
||
* Ruling P23 (re-review F1): candidates by a wide, fail-closed prefilter (ID, status and type
|
||
* only), then their content in chunks, parsed in PHP (UsageMatcher) – every format Divi renders.
|
||
*/
|
||
public function testFindsEveryFormatDiviRendersFromCandidates(): void
|
||
{
|
||
$bs = chr(92);
|
||
$contents = [
|
||
1053 => '<!-- wp:divi/global-layout {"globalModule":"1058","blockName":"divi/text"} /-->',
|
||
1054 => '[et_pb_section global_module="01058"][/et_pb_section]',
|
||
1055 => '<!-- wp:divi/global-layout {"' . $bs . 'u0067lobalModule":"1058"} /-->',
|
||
1056 => '<!-- wp:divi/global-layout {"globalModule":"' . $bs . 'u00310' . '58"} /-->',
|
||
1057 => '<!-- wp:divi/global-layout {"globalModule":"1059"} /-->',
|
||
1061 => '<!-- wp:divi/global-layout {"' . $bs . 'u0067lobalModule":"1058",} /-->',
|
||
];
|
||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||
$queries = [];
|
||
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries, $contents): array {
|
||
$queries[] = $sql;
|
||
if (str_contains($sql, 'post_content FROM')) {
|
||
preg_match('/IN \(([0-9, ]+)\)/', $sql, $in);
|
||
$ids = array_map('intval', explode(',', $in[1]));
|
||
|
||
return array_map(static fn (int $id): array => ['ID' => (string) $id, 'post_content' => $contents[$id]], $ids);
|
||
}
|
||
|
||
return array_map(
|
||
static fn (int $id): array => ['ID' => (string) $id, 'post_status' => 'publish', 'post_type' => 'page'],
|
||
array_keys($contents)
|
||
);
|
||
});
|
||
|
||
$rows = (new GlobalUsages($db))->rows(1058);
|
||
|
||
self::assertSame([1053, 1054, 1055, 1056, 1061], array_column($rows, 'id'), '1061: undecodable, fail closed');
|
||
self::assertStringStartsWith('SELECT ID, post_status, post_type FROM', $queries[0]);
|
||
self::assertStringNotContainsString('post_content,', $queries[0]);
|
||
// Ruling P24: case-insensitive whatever the column collation.
|
||
self::assertStringContainsString("LOWER(post_content) LIKE '%globalmodule%'", $queries[0]);
|
||
self::assertStringContainsString("LOWER(post_content) LIKE '%global" . $bs . "_module%'", $queries[0]);
|
||
self::assertStringContainsString("post_content REGEXP '" . $bs . $bs . 'u00[4-7][0-9a-fA-F]' . "'", $queries[0]);
|
||
self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[0]);
|
||
self::assertStringContainsString("post_type <> 'revision'", $queries[0]);
|
||
self::assertCount(2, $queries, 'one candidate query, one content chunk');
|
||
}
|
||
|
||
/** Audit L1: contents are read in chunks; beyond the parse cap candidates count as references. */
|
||
public function testReadsContentsInChunksAndFailsClosedBeyondTheCap(): void
|
||
{
|
||
$total = GlobalUsages::PARSE_CAP + 3;
|
||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||
$chunks = 0;
|
||
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$chunks, $total): array {
|
||
if (str_contains($sql, 'post_content FROM')) {
|
||
++$chunks;
|
||
preg_match('/IN \(([0-9, ]+)\)/', $sql, $in);
|
||
|
||
return array_map(
|
||
static fn (string $id): array => ['ID' => trim($id), 'post_content' => 'nichts'],
|
||
explode(',', $in[1])
|
||
);
|
||
}
|
||
|
||
return array_map(
|
||
static fn (int $id): array => ['ID' => (string) $id, 'post_status' => 'draft', 'post_type' => 'page'],
|
||
range(1, $total)
|
||
);
|
||
});
|
||
|
||
$rows = (new GlobalUsages($db))->rows(1058);
|
||
|
||
self::assertSame((int) ceil(GlobalUsages::PARSE_CAP / GlobalUsages::CONTENT_CHUNK), $chunks);
|
||
self::assertSame(range(GlobalUsages::PARSE_CAP + 1, $total), array_column($rows, 'id'));
|
||
}
|
||
|
||
/** 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 = null): 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']);
|
||
}
|
||
|
||
/** @return list<array{id: int, status: string, type: string}> */
|
||
private static function themeBuilderRows(): array
|
||
{
|
||
return [
|
||
['id' => 1053, 'status' => 'publish', 'type' => 'page'],
|
||
['id' => 1061, 'status' => 'publish', 'type' => 'et_header_layout'],
|
||
['id' => 1062, 'status' => 'publish', 'type' => 'et_body_layout'],
|
||
['id' => 1063, 'status' => 'publish', 'type' => 'et_footer_layout'],
|
||
['id' => 1064, 'status' => 'publish', 'type' => 'et_template'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Audit L6, ruling Q17 M1 (review I-2): without an injected Theme Builder access, Theme Builder rows need what
|
||
* ThemeBuilderAccess::canUse() needs – Divi's role option theme_builder, edit_others_posts and
|
||
* edit_theme_options; without them they are neither listed nor named, only counted – and still block.
|
||
*/
|
||
public function testThemeBuilderRowsNeedTheThemeBuilderRightsByDefault(): void
|
||
{
|
||
Functions\when('get_post')->justReturn(null);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
|
||
$with = LibraryFixtures::usages(self::themeBuilderRows(), LibraryFixtures::roles())->report(1058);
|
||
self::assertSame([], $with->blocked);
|
||
self::assertCount(5, $with->items);
|
||
|
||
$without = LibraryFixtures::usages(self::themeBuilderRows(), LibraryFixtures::roles(['theme_builder']))
|
||
->report(1058);
|
||
self::assertSame([1053], array_column($without->items, 'post_id'));
|
||
self::assertSame([], $without->blocked);
|
||
self::assertSame(4, $without->hidden);
|
||
self::assertSame(4, $without->blockedHidden);
|
||
self::assertTrue($without->blocks());
|
||
|
||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_theme_options');
|
||
$noThemeOptions = LibraryFixtures::usages(self::themeBuilderRows(), LibraryFixtures::roles())->report(1058);
|
||
self::assertSame(4, $noThemeOptions->blockedHidden);
|
||
self::assertSame([], $noThemeOptions->blocked);
|
||
}
|
||
|
||
/** Ruling Q17 M1: a Theme Builder row the account may see but not use (no edit right in the TB) blocks by ID. */
|
||
public function testAVisibleButUnusableThemeBuilderRowBlocksByItsId(): void
|
||
{
|
||
Functions\when('get_post')->justReturn(null);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$asked = 0;
|
||
$themeBuilder = static function () use (&$asked): array {
|
||
++$asked;
|
||
|
||
return ['visible' => true, 'usable' => false];
|
||
};
|
||
|
||
$report = LibraryFixtures::usages(self::themeBuilderRows(), null, $themeBuilder)->report(1058);
|
||
|
||
self::assertCount(5, $report->items);
|
||
self::assertSame([1061, 1062, 1063, 1064], $report->blocked);
|
||
self::assertSame(1, $asked, 'evaluated once per report');
|
||
}
|
||
|
||
/** Review I-2 scenario B: without Theme Builder visibility a TB row is only counted – never its ID, title or type. */
|
||
public function testWithoutThemeBuilderVisibilityRowsAreOnlyCounted(): void
|
||
{
|
||
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')->justReturn(true);
|
||
|
||
$report = LibraryFixtures::usages(
|
||
self::themeBuilderRows(),
|
||
null,
|
||
static fn (): array => ['visible' => false, 'usable' => false]
|
||
)->report(1058);
|
||
|
||
self::assertSame(
|
||
[['post_id' => 1053, 'title' => 'Titel 1053', 'status' => 'publish', 'type' => 'page']],
|
||
$report->items
|
||
);
|
||
self::assertSame(4, $report->hidden);
|
||
self::assertSame([], $report->blocked);
|
||
self::assertSame(4, $report->blockedHidden);
|
||
$json = (string) json_encode($report->details());
|
||
self::assertStringNotContainsString('1061', $json);
|
||
self::assertStringNotContainsString('et_header_layout', $json);
|
||
}
|
||
|
||
/** A page row is unaffected by the Theme Builder access. */
|
||
public function testPageRowsIgnoreTheThemeBuilderAccess(): void
|
||
{
|
||
Functions\when('get_post')->justReturn(null);
|
||
Functions\when('current_user_can')->alias(static fn (string $cap, mixed $id = null): bool => $cap !== 'edit_post');
|
||
$asked = 0;
|
||
$themeBuilder = static function () use (&$asked): array {
|
||
++$asked;
|
||
|
||
return ['visible' => false, 'usable' => false];
|
||
};
|
||
|
||
$report = LibraryFixtures::usages(LibraryFixtures::rows([1053]), null, $themeBuilder)->report(1058);
|
||
|
||
self::assertSame([1053], array_column($report->items, 'post_id'));
|
||
self::assertSame([1053], $report->blocked);
|
||
self::assertSame(0, $asked, 'no Theme Builder row, no Theme Builder check');
|
||
}
|
||
|
||
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()));
|
||
}
|
||
|
||
public function testRowsForUsesTheConditionAndMatcherOfTheQuery(): void
|
||
{
|
||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||
$queries = [];
|
||
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array {
|
||
$queries[] = $sql;
|
||
|
||
return str_contains($sql, 'post_content FROM')
|
||
? [['ID' => '5', 'post_content' => 'treffer'], ['ID' => '6', 'post_content' => 'nichts']]
|
||
: [['ID' => '5', 'post_status' => 'draft', 'post_type' => 'page'],
|
||
['ID' => '6', 'post_status' => 'draft', 'post_type' => 'page']];
|
||
});
|
||
$query = new class implements \LIT\MCP\Integrations\Divi\Library\UsageQuery {
|
||
public function candidateCondition(\wpdb $db): string
|
||
{
|
||
return '(MARKER)';
|
||
}
|
||
|
||
public function matches(string $content): bool
|
||
{
|
||
return $content === 'treffer';
|
||
}
|
||
};
|
||
|
||
$rows = (new GlobalUsages($db))->rowsFor($query);
|
||
|
||
self::assertSame([['id' => 5, 'status' => 'draft', 'type' => 'page']], $rows);
|
||
self::assertStringContainsString('WHERE (MARKER) AND post_type', $queries[0]);
|
||
}
|
||
}
|