'', 1054 => '[et_pb_section global_module="01058"][/et_pb_section]', 1055 => '', 1056 => '', 1057 => '', 1061 => '', ]; $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 */ 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]); } }