diff --git a/docs/guides/divi-library.md b/docs/guides/divi-library.md index 07cd346..cf7b9ab 100644 --- a/docs/guides/divi-library.md +++ b/docs/guides/divi-library.md @@ -81,9 +81,10 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv Die Bestätigung wird abgelehnt (`forbidden`), wenn das Konto einen veröffentlichten, privaten oder geplanten Eintrag, der das Modul zeigt, nicht bearbeiten darf – schon der Probelauf warnt davor und nennt diese IDs in `blocked_ids`, nicht lesbare nur als Anzahl in `blocked_hidden_count` (Entwürfe blockieren nicht). -Theme-Builder-Layouts (Header, Body, Footer, Vorlagen) blockieren außerdem, wenn die Divi-Rolle des Kontos -„Theme Builder“ nicht erlaubt. Zeigen -mehr als 2000 veröffentlichte, private oder geplante Einträge das Modul, lehnt LIT MCP die Bestätigung ab +Theme-Builder-Layouts (Header, Body, Footer, Vorlagen) blockieren außerdem, wenn das Konto den Theme Builder +nicht benutzen darf (Divi-Rolle „Theme Builder“, `edit_others_posts`, `edit_theme_options`) oder die Gruppe +„Divi Theme Builder“ auf „nur lesen“ steht; ohne Zugang zum Theme Builder erscheinen sie nur als Anzahl +(`hidden_count`, `blocked_hidden_count`), nie mit ID oder Titel. Zeigen mehr als 2000 veröffentlichte, private oder geplante Einträge das Modul, lehnt LIT MCP die Bestätigung ab (`unsupported`); solche Einträge im Divi-Builder bearbeiten. Sind Revisionen für die Bibliothek abgeschaltet, lehnt das Werkzeug die Änderung außerdem ab (`unsupported`), wenn der Papierkorb deaktiviert ist oder das Konto kein `unfiltered_html` hat (die Sicherungskopie wäre dann nicht verlustfrei). diff --git a/readme.txt b/readme.txt index de0129e..513602d 100644 --- a/readme.txt +++ b/readme.txt @@ -73,7 +73,8 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, verlangt dafür einen Probelauf mit Liste der betroffenen Seiten und confirm, legt vorher eine Revision (bei abgeschalteten Revisionen eine Sicherungskopie im Papierkorb) an und leert danach Divis CSS-Cache aller Seiten und den Theme-Builder-Cache. Die Bestätigung wird abgelehnt, wenn der Benutzer einen veröffentlichten, - privaten oder geplanten Eintrag, der das Modul zeigt, nicht bearbeiten darf. + privaten oder geplanten Eintrag, der das Modul zeigt, nicht bearbeiten darf – bei Theme-Builder-Layouts + auch ohne die Theme-Builder-Rechte; ohne Zugang zum Theme Builder nennt LIT MCP solche Layouts nur als Anzahl. * Divis Rollen-Editor gilt zusätzlich zu den WordPress-Rechten: ohne „Divi-Bibliothek“, „In der Bibliothek speichern“, „Aus der Bibliothek hinzufügen“ bzw. „Globale Bibliothekselemente bearbeiten“ lehnt LIT MCP ab. * Divi Theme Builder: neue Vorlagen legt LIT MCP deaktiviert an (über Divis eigene Funktionen, mit Layouts für diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 75a8134..55eb00f 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -170,10 +170,22 @@ final class DiviIntegration implements Integration $this->log, $this->modules, $this->diviWritable, - $this->schema + $this->schema, + $this->globalUsages() ); } + /** + * The usages of library entries with this integration's Theme Builder access (ruling Q17 M1, review I-2): + * Theme Builder posts are named only while ThemeBuilderAccess::visible(), and a global edit that changes them + * needs the rights of every Theme Builder write (canUse(), in visible()) and a writable "Divi Theme Builder" + * group – as the Divi tools on those layouts. + */ + public function globalUsages(?\wpdb $db = null): GlobalUsages + { + return new GlobalUsages($db, $this->access->roles(), $this->themeBuilderAccess->usageState(...)); + } + /** The group "Divi Theme Builder" (lit/tb-*), sharing this integration's Theme Builder access (Etappe 3b). */ public function themeBuilder(): ThemeBuilderIntegration { @@ -194,7 +206,7 @@ final class DiviIntegration implements Integration $this->log, $this->design, new DiviComposition($this->modules, $this->design), - new GlobalEdits($this->access, new GlobalUsages(null, $this->access->roles())), + new GlobalEdits($this->access, $this->globalUsages()), new ThemeBuilderEdits($this->themeBuilderAccess, fields: new DynamicFields($this->schema)), new SimilarSections($this->access) ); diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php index adb5e66..afec7e5 100644 --- a/src/Integrations/Divi/Library/GlobalUsages.php +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Library; +use Closure; +use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use WP_Post; defined('ABSPATH') || exit; @@ -16,8 +18,10 @@ defined('ABSPATH') || exit; * rights of the caller are then checked in chunks with a bounded post cache: * - readable posts are listed (at most LIMIT, with title); unreadable ones only count (audit L4); * - live posts (ruling P8a) the caller may not edit_post block a confirmed edit – by ID when - * readable, else as a count; Theme Builder layouts also without Divi's theme_builder role option - * (audit L6); above HARD_CAP live posts the edit is refused as a whole. + * readable, else as a count; above HARD_CAP live posts the edit is refused as a whole; + * - Theme Builder posts follow the Theme Builder access (ruling Q17 M1, review I-2, audit L1/L6): readable + * only while the Theme Builder is visible to the caller (else counted, never named – P12, like + * ThemeBuilderAccess), editable only while the caller may use it (canUse()) plus edit_post. * Not final so tests can mock rows(). */ class GlobalUsages @@ -46,13 +50,23 @@ class GlobalUsages /** Every post that exists for the user: listed in the dry run. */ private const LISTED = "post_status NOT IN ('trash', 'auto-draft', 'inherit')"; - /** Theme Builder post types (Divi's et_theme_builder_get_layout_post_types() plus the templates). */ - public const THEME_BUILDER_TYPES = ['et_header_layout', 'et_body_layout', 'et_footer_layout', 'et_template']; - private DiviRoles $roles; - public function __construct(private ?\wpdb $db = null, ?DiviRoles $roles = null) - { + /** @var array{visible: bool, usable: bool}|null The Theme Builder access of the current report(). */ + private ?array $themeBuilderState = null; + + /** + * @param (Closure(): array{visible: bool, usable: bool})|null $themeBuilder The caller's Theme Builder access + * (DiviIntegration wraps ThemeBuilderAccess – a closure keeps Library free of it): visible = TB objects exist + * for the caller, usable = the caller may change them; asked at most once per report(). null = what + * ThemeBuilderAccess::canUse() needs, for both (Divi's role option theme_builder, edit_others_posts, + * edit_theme_options). + */ + public function __construct( + private ?\wpdb $db = null, + ?DiviRoles $roles = null, + private ?Closure $themeBuilder = null + ) { $this->roles = $roles ?? new DiviRoles(); } @@ -67,6 +81,7 @@ class GlobalUsages $tooMany = $withBlocked && $live > self::HARD_CAP; $checkEdit = $withBlocked && ! $tooMany; + $this->themeBuilderState = null; $items = []; $hidden = 0; $blocked = []; @@ -95,7 +110,7 @@ class GlobalUsages _prime_post_caches($ids, false, false); foreach ($todo as $row) { $id = $row['id']; - $readable = current_user_can('read_post', $id); + $readable = $this->readable($row); if ($readable && count($items) < self::LIMIT) { $post = get_post($id); $items[] = [ @@ -209,21 +224,63 @@ class GlobalUsages } /** - * Ruling P8a: whether the caller may edit_post the live post $row; a Theme Builder layout or - * template additionally needs Divi's role option theme_builder (audit L6 – as Divi's own - * et_pb_is_allowed check for TB layouts, builder/functions.php). + * Whether $row may be named to the caller: read_post; a Theme Builder post only while the Theme Builder is + * visible to the caller (review I-2 B: otherwise it reads like a missing ID elsewhere, P12). + * + * @param array{id: int, status: string, type: string} $row + */ + private function readable(array $row): bool + { + if (self::isThemeBuilder($row) && ! $this->themeBuilder()['visible']) { + return false; + } + + return current_user_can('read_post', $row['id']); + } + + /** + * Ruling P8a: whether the caller may edit_post the live post $row; a Theme Builder post additionally needs the + * Theme Builder rights (ruling Q17 M1 – as every Theme Builder gate, ThemeBuilderAccess::canUse(); audit L6). * * @param array{id: int, status: string, type: string} $row */ private function editable(array $row): bool { - if (in_array($row['type'], self::THEME_BUILDER_TYPES, true) && ! $this->roles->allows('theme_builder')) { + if (self::isThemeBuilder($row) && ! $this->themeBuilder()['usable']) { return false; } return current_user_can('edit_post', $row['id']); } + /** @param array{id: int, status: string, type: string} $row */ + private static function isThemeBuilder(array $row): bool + { + return in_array($row['type'], DiviThemeBuilder::postTypes(), true); + } + + /** @return array{visible: bool, usable: bool} */ + private function themeBuilder(): array + { + if ($this->themeBuilderState !== null) { + return $this->themeBuilderState; + } + + if ($this->themeBuilder !== null) { + $state = ($this->themeBuilder)(); + } else { + $open = $this->roles->allows('theme_builder') + && current_user_can('edit_others_posts') + && current_user_can('edit_theme_options'); + $state = ['visible' => $open, 'usable' => $open]; + } + + return $this->themeBuilderState = [ + 'visible' => (bool) ($state['visible'] ?? false), + 'usable' => (bool) ($state['usable'] ?? false), + ]; + } + private function db(): \wpdb { return $this->db ?? $GLOBALS['wpdb']; diff --git a/src/Integrations/Divi/Library/LibraryIntegration.php b/src/Integrations/Divi/Library/LibraryIntegration.php index a975f47..8c41c0b 100644 --- a/src/Integrations/Divi/Library/LibraryIntegration.php +++ b/src/Integrations/Divi/Library/LibraryIntegration.php @@ -25,7 +25,11 @@ defined('ABSPATH') || exit; */ final class LibraryIntegration implements Integration { - /** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always. */ + /** + * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always. + * @param GlobalUsages|null $usages The usages with the Theme Builder access (DiviIntegration::globalUsages(), + * review I-2); null = GlobalUsages' own fail-safe Theme Builder check. + */ public function __construct( private LibraryAccess $access, private DiviPageLoader $loader, @@ -35,7 +39,8 @@ final class LibraryIntegration implements Integration private ChangeLog $log, private DiviModules $modules, private ?Closure $diviWritable = null, - private ?ContentSchema $schema = null + private ?ContentSchema $schema = null, + private ?GlobalUsages $usages = null ) { } @@ -61,7 +66,7 @@ final class LibraryIntegration implements Integration $this->access, $this->loader, $this->design, - new GlobalUsages(null, $this->access->roles()) + $this->usages ?? new GlobalUsages(null, $this->access->roles()) ), new LibrarySaveAbility( $this->access, diff --git a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php index 9a20cee..0457fa2 100644 --- a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php +++ b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php @@ -64,6 +64,17 @@ class DiviThemeBuilder /** Actions of wp_insert_post() that name a post's ID after its row exists, earliest first (Q19). */ private const INSERT_ACTIONS = ['clean_post_cache', 'save_post', 'wp_insert_post']; + /** + * Every Theme Builder post type – the Theme Builder posts, the templates and the layouts (review M-3: one list for + * GlobalUsages and lit/library-find-similar; ContentSchema keeps its own literal list in the WordPress layer). + * + * @return list + */ + public static function postTypes(): array + { + return array_merge([self::THEME_BUILDER, self::TEMPLATE], array_values(self::LAYOUT_TYPES)); + } + /** "header", "body" or "footer" for a layout post type, else null. */ public static function areaOf(string $postType): ?string { diff --git a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php index 201c2fc..8f75493 100644 --- a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php +++ b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php @@ -66,6 +66,20 @@ class ThemeBuilderAccess return ($this->visible === null || ($this->visible)()) && $this->canUse() === null; } + /** + * The Theme Builder access of a global library edit (GlobalUsages, ruling Q17 M1, review I-2): visible = Theme + * Builder posts may be named (visible()); usable = a global edit may change them – the rights of every Theme + * Builder write (canUse(), part of visible()) and a writable group, as the Divi tools on layouts. + * + * @return array{visible: bool, usable: bool} + */ + public function usageState(): array + { + $visible = $this->visible(); + + return ['visible' => $visible, 'usable' => $visible && $this->writableGate() === null]; + } + public function writableGate(): ?WP_Error { if ($this->writable === null || ($this->writable)()) { diff --git a/tests/Support/LibraryFixtures.php b/tests/Support/LibraryFixtures.php index f59f782..f22d86c 100644 --- a/tests/Support/LibraryFixtures.php +++ b/tests/Support/LibraryFixtures.php @@ -118,12 +118,15 @@ final class LibraryFixtures * * @param list|\Closure(int): list $rows */ - public static function usages(array|\Closure $rows, ?DiviRoles $roles = null): GlobalUsages - { + public static function usages( + array|\Closure $rows, + ?DiviRoles $roles = null, + ?\Closure $themeBuilder = null + ): GlobalUsages { Functions\when('_get_non_cached_ids')->returnArg(1); Functions\when('_prime_post_caches')->justReturn(null); Functions\when('wp_cache_delete')->justReturn(true); - $usages = Mockery::mock(GlobalUsages::class, [null, $roles ?? self::roles()])->makePartial(); + $usages = Mockery::mock(GlobalUsages::class, [null, $roles ?? self::roles(), $themeBuilder])->makePartial(); $usages->allows('rows')->andReturnUsing(static fn (int $id): array => is_array($rows) ? $rows : $rows($id)); return $usages; diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index b60f5c9..d7a39cb 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -94,6 +94,38 @@ final class DiviIntegrationTest extends TestCase } } + /** + * Review I-2 (ruling Q17 M1): the library usages follow the Theme Builder access – without it (here: no Divi + * role check in unit tests, so canUse() fails) TB layouts are counted and block, never named. + * ThemeBuilderAccessTest covers usageState() itself. + */ + public function testGlobalUsagesFollowTheThemeBuilderAccess(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn(null); + Functions\when('_get_non_cached_ids')->returnArg(1); + Functions\when('_prime_post_caches')->justReturn(null); + Functions\when('wp_cache_delete')->justReturn(true); + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->allows('get_results')->andReturnUsing(static fn (string $sql): array => str_contains($sql, 'post_content FROM') + ? [ + ['ID' => '1053', 'post_content' => ''], + ['ID' => '1061', 'post_content' => ''], + ] + : [ + ['ID' => '1053', 'post_status' => 'publish', 'post_type' => 'page'], + ['ID' => '1061', 'post_status' => 'publish', 'post_type' => 'et_header_layout'], + ]); + + $report = (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden())) + ->globalUsages($db) + ->report(1058); + + self::assertSame([1053], array_column($report->items, 'post_id')); + self::assertSame([], $report->blocked); + self::assertSame(1, $report->blockedHidden); + } + /** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */ public function testNodeCopyFollowsLitDiviInsert(): void { diff --git a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php index 92b09da..c99c7ff 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php @@ -99,7 +99,7 @@ final class GlobalUsagesTest extends TestCase 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 + static fn (string $cap, ?int $id = null): bool => $id !== 1061 ); $report = $usages->report(1058); @@ -167,24 +167,108 @@ final class GlobalUsagesTest extends TestCase self::assertSame(GlobalUsages::CHECK_CAP, $asked['read_post']); } - /** Audit L6: Theme Builder layouts additionally need Divi's role option theme_builder. */ - public function testThemeBuilderLayoutsNeedTheThemeBuilderRole(): void + /** @return list */ + private static function themeBuilderRows(): array { - $rows = [ + 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); - $without = LibraryFixtures::usages($rows, LibraryFixtures::roles(['theme_builder']))->report(1058); - $with = LibraryFixtures::usages($rows, LibraryFixtures::roles())->report(1058); - - self::assertSame([1061, 1062, 1063, 1064], $without->blocked); + $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 diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php index 872eb8a..cff27d2 100644 --- a/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php @@ -33,6 +33,25 @@ final class ThemeBuilderAccessTest extends TestCase self::assertToolError(ThemeBuilderFixtures::access()->canUse(), 'forbidden'); } + /** Ruling Q17 M1 (review I-2): what a global library edit may do with Theme Builder posts (GlobalUsages). */ + public function testUsageStateOfAGlobalEdit(): void + { + LibraryFixtures::given([]); + self::assertSame(['visible' => true, 'usable' => true], ThemeBuilderFixtures::access()->usageState()); + self::assertSame(['visible' => true, 'usable' => false], ThemeBuilderFixtures::access([], false)->usageState()); + self::assertSame( + ['visible' => false, 'usable' => false], + ThemeBuilderFixtures::access([], true, null, false)->usageState() + ); + self::assertSame( + ['visible' => false, 'usable' => false], + ThemeBuilderFixtures::access(['theme_builder'])->usageState() + ); + + LibraryFixtures::given([], ['edit_theme_options']); + self::assertSame(['visible' => false, 'usable' => false], ThemeBuilderFixtures::access()->usageState()); + } + /** Ruling Q15: Divi's Theme Builder page also needs edit_theme_options (theme-builder.php:2059-2063). */ public function testWithoutEditThemeOptionsTheThemeBuilderIsHidden(): void {