fix: global usages follow the Theme Builder access (ruling Q17 M1)

GlobalUsages gets the Theme Builder access injected as a closure
(ThemeBuilderAccess::usageState(), wired at both construction sites through
DiviIntegration::globalUsages()). Theme Builder posts are named only while
the Theme Builder is visible to the account, otherwise only counted (also
in blocked_ids); a global edit that changes them needs canUse(), a writable
"Divi Theme Builder" group and edit_post. Without an injected access the
fail-safe default checks what canUse() checks. The duplicate
THEME_BUILDER_TYPES list gives way to DiviThemeBuilder::postTypes().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-27 23:00:36 +02:00
co-authored by Claude Opus 5.5
parent 7590da4d96
commit 43dc3fb889
11 changed files with 271 additions and 32 deletions
+6 -3
View File
@@ -118,12 +118,15 @@ final class LibraryFixtures
*
* @param list<array{id: int, status: string, type: string}>|\Closure(int): list<array{id: int, status: string, type: string}> $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;
@@ -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' => '<!-- wp:divi/global-layout {"globalModule":"1058"} /-->'],
['ID' => '1061', 'post_content' => '<!-- wp:divi/global-layout {"globalModule":"1058"} /-->'],
]
: [
['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
{
@@ -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<array{id: int, status: string, type: string}> */
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
@@ -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
{