fix: library IDs stay hidden without library access (ruling P13)

Outline, node-get, write outlines, the render summary and replace-text's
opaque-node match name no library ID unless the library group is visible
and the user has divi_library; read-only still names them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:00:39 +02:00
co-authored by Claude Opus 5.5
parent 06f13799de
commit c7ce924e5a
19 changed files with 351 additions and 28 deletions
@@ -183,4 +183,27 @@ final class OutlineTest extends TestCase
{
return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name);
}
/** Ruling P13: without library access the outline says "global", but not which entry. */
public function testGlobalIdsCanBeHidden(): void
{
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
$outline = new Outline([], false);
$rows = $outline->rows(NodeIndex::build((new Parser())->parse($content)));
$byId = array_column($rows, null, 'id');
self::assertNull($byId['g0']['global_id']);
self::assertSame('section', $byId['g0']['type']);
self::assertStringNotContainsString('1058', $outline->text($rows));
self::assertStringNotContainsString('1059', $outline->text($rows));
self::assertStringNotContainsString('[global', $outline->text($rows));
self::assertStringContainsString('g0 section "Global Abschnitt" (nur lesbar)', $outline->text($rows));
self::assertStringNotContainsString('1058', (string) json_encode($rows));
self::assertStringNotContainsString('1059', (string) json_encode($rows));
$summary = $outline->summary(NodeIndex::build((new Parser())->parse($content)));
self::assertStringNotContainsString('1058', (string) $summary['text']);
$shown = new Outline();
self::assertStringContainsString('[global 1058]', $shown->text($shown->rows(NodeIndex::build((new Parser())->parse($content)))));
}
}
@@ -123,4 +123,25 @@ final class DiviIntegrationTest extends TestCase
self::assertInstanceOf(WP_Error::class, $refusal);
self::assertToolError($refusal, 'not_found');
}
/**
* Ruling P13: library IDs follow the integration's library access – hidden with the group disabled
* and without the library right (no Divi in unit tests: DiviRoles allows nothing). The positive
* cases, "nur lesen" included, are in LibraryAccessTest (defining et_pb_is_allowed here would leak).
*/
public function testLibraryIdsFollowTheLibraryAccess(): void
{
LibraryFixtures::given([]);
$schema = Mockery::mock(ContentSchema::class);
$asked = 0;
$hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static function () use (&$asked): bool {
++$asked;
return false;
});
self::assertFalse($hidden->libraryIdsVisible());
self::assertSame(1, $asked);
self::assertFalse((new DiviIntegration($schema, FakeChangeLog::forbidden()))->libraryIdsVisible());
}
}
@@ -180,4 +180,32 @@ final class LibraryAccessTest extends TestCase
LibraryFixtures::given([], ['edit_posts']);
self::assertToolError(LibraryFixtures::access()->canInsert(), 'forbidden');
}
/** Ruling P13: library IDs are named only while the library is visible and the user has its right. */
public function testRevealsIdsOnlyWithTheVisibleLibraryAndItsRight(): void
{
LibraryFixtures::given([]);
self::assertTrue(LibraryFixtures::access()->revealsIds());
self::assertFalse(LibraryFixtures::access(['divi_library'])->revealsIds());
$hidden = new LibraryAccess(LibraryFixtures::store(), LibraryFixtures::roles(), null, static fn (): bool => false);
self::assertFalse($hidden->revealsIds());
LibraryFixtures::given([], ['edit_posts']);
self::assertFalse(LibraryFixtures::access()->revealsIds());
}
/** Ruling P13: "nur lesen" (group read-only) still names library IDs – only "deaktiviert" hides them. */
public function testAReadOnlyLibraryStillRevealsIds(): void
{
LibraryFixtures::given([]);
self::assertTrue(LibraryFixtures::access([], false)->revealsIds());
$readOnly = new LibraryAccess(
LibraryFixtures::store(),
LibraryFixtures::roles(),
static fn (): bool => false,
static fn (): bool => true
);
self::assertTrue($readOnly->revealsIds());
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
@@ -128,7 +129,7 @@ final class DiviReadAbilitiesTest extends TestCase
public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void
{
DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
$ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design());
$ability = new DiviNodeGetAbility(LibraryFixtures::loader(), $this->design());
$global = $ability->execute(['post_id' => 7, 'id' => 'g0']);
$plain = $ability->execute(['post_id' => 7, 'id' => 's0']);
@@ -140,6 +141,56 @@ final class DiviReadAbilitiesTest extends TestCase
self::assertNull($plain['global']);
}
/** Ruling P13: without the library right node-get and outline name no library ID. */
public function testWithoutLibraryAccessNoLibraryIdIsNamed(): void
{
DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
$loaders = [
'no divi_library right' => LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])),
'group disabled' => LibraryFixtures::loader(new LibraryAccess(
LibraryFixtures::store(),
LibraryFixtures::roles(),
null,
static fn (): bool => false
)),
'no library access at all' => new DiviPageLoader(DiviFixtures::schema()),
];
foreach ($loaders as $case => $loader) {
$nodeGet = new DiviNodeGetAbility($loader, $this->design());
$global = $nodeGet->execute(['post_id' => 7, 'id' => 'g0']);
$outline = (new DiviOutlineAbility($loader, $this->design()))->execute(['post_id' => 7]);
self::assertNull($global['global']['id'], $case);
self::assertSame('divi/section', $global['global']['block_name'], $case);
self::assertStringContainsString('Divi-Bibliothek', $global['global']['hint'], $case);
self::assertArrayNotHasKey('globalModule', $global['attrs'], $case);
self::assertFalse($global['editable'], $case);
self::assertStringNotContainsString('1058', (string) json_encode($global), $case);
self::assertStringNotContainsString('1058', (string) json_encode($outline), $case);
self::assertStringNotContainsString('1059', (string) json_encode($outline), $case);
self::assertStringNotContainsString('[global', (string) json_encode($outline), $case);
foreach (array_column($outline['nodes'], 'id') as $id) {
$node = $nodeGet->execute(['post_id' => 7, 'id' => $id]);
self::assertStringNotContainsString('1058', (string) json_encode($node), $case . ' ' . $id);
self::assertStringNotContainsString('1059', (string) json_encode($node), $case . ' ' . $id);
}
}
}
/** Ruling P13: "nur lesen" (group read-only) does not hide library IDs. */
public function testAReadOnlyLibraryStillNamesLibraryIds(): void
{
DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
$loader = LibraryFixtures::loader(LibraryFixtures::access([], false));
$global = (new DiviNodeGetAbility($loader, $this->design()))->execute(['post_id' => 7, 'id' => 'g0']);
$outline = (new DiviOutlineAbility($loader, $this->design()))->execute(['post_id' => 7]);
self::assertSame('1058', $global['global']['id']);
self::assertStringContainsString('[global 1058]', $outline['outline']);
}
public function testOutlineOfALibraryItem(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
@@ -15,6 +15,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
@@ -38,13 +39,19 @@ final class DiviReplaceTextAbilityTest extends TestCase
private array $failures = [];
/** @param list<int> $ids Rows of the prefilter query. */
private function ability(?string $content = null, bool $unfiltered = false, array $ids = [7]): DiviReplaceTextAbility
{
private function ability(
?string $content = null,
bool $unfiltered = false,
array $ids = [7],
bool $library = false
): DiviReplaceTextAbility {
$this->post = DiviFixtures::givenPost($content ?? DiviFixtures::markup('synthetic-basic.html'));
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]);
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
$schema = DiviFixtures::schema();
$loader = new DiviPageLoader($schema);
$loader = $library
? new DiviPageLoader($schema, new Parser(), LibraryFixtures::access())
: new DiviPageLoader($schema);
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->allows('get_col')->andReturnUsing(function (string $sql) use ($ids): array {
$this->queries[] = $sql;
@@ -420,6 +427,43 @@ final class DiviReplaceTextAbilityTest extends TestCase
self::assertSame([], $this->saved);
}
/**
* Ruling P13: without library access a search for a library ID finds nothing – the ID a global
* instance (and its children's globalParent) stores is as good as absent; with access it is reported.
*/
public function testWithoutLibraryAccessLibraryIdsAreNoHits(): void
{
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
foreach (['1058', '1059', '"globalModule":"1058"', 'globalParent":"1058'] as $search) {
$ability = $this->ability($content);
$dry = $ability->execute(['search' => $search, 'replace' => 'x']);
$result = $this->replaceFor($ability, ['search' => $search, 'replace' => 'x']);
self::assertSame([], $dry['skipped_nodes'], $search);
self::assertSame([], $dry['matches'], $search);
self::assertSame([], $result['skipped_nodes'], $search);
self::assertStringNotContainsString('1058', (string) json_encode([$dry, $result]), $search);
self::assertStringNotContainsString('1059', (string) json_encode([$dry, $result]), $search);
self::assertSame([], $this->saved);
}
$shown = $this->ability($content, false, [7], true)->execute(['search' => '1058', 'replace' => 'x']);
self::assertSame(['g0'], array_column($shown['skipped_nodes'], 'id'));
}
/** Ruling P13: text of a global instance is still reported without library access (not an ID). */
public function testWithoutLibraryAccessGlobalTextIsStillReported(): void
{
$global = ['divi/text', ['globalModule' => '123', 'content' => ['innerContent' => ['desktop' => ['value' => '<p>Müller</p>']]]]];
$ability = $this->ability(self::page([$global]));
$dry = $ability->execute(['search' => 'Müller', 'replace' => 'Schmidt']);
$byId = $ability->execute(['search' => '123', 'replace' => 'x']);
self::assertSame(['s0.r0.c0.g0'], array_column($dry['skipped_nodes'], 'id'));
self::assertSame([], $byId['skipped_nodes']);
}
/** No effective change (search == replacement, cleaned replacement == match): nothing saved. */
public function testNoEffectiveChangeSavesNothing(): void
{
@@ -8,6 +8,8 @@ use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
use WP_Error;
final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase
@@ -240,4 +242,24 @@ final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase
self::assertStringContainsString('nur im Divi-Builder', $client['fields']['changes[0].attrs.module.decoration.attributes']);
self::assertSame([], $this->saved);
}
/** Ruling P13: the outline of a write result names library IDs only with library access. */
public function testTheWriteOutlineNamesLibraryIdsOnlyWithLibraryAccess(): void
{
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
$changes = [['id' => 's0.r0.c0.m0', 'set' => ['text' => 'Termin buchen']]];
$hidden = $this->updateOn($content, $changes);
$failures = [];
$this->writer = DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader());
$shown = $this->updateOn($content, $changes);
self::assertTrue($hidden['ok']);
self::assertStringContainsString('s0.r0.c0.g0 text', (string) $hidden['outline']);
self::assertStringContainsString('(nur lesbar)', (string) $hidden['outline']);
self::assertStringNotContainsString('1059', (string) json_encode($hidden));
self::assertStringNotContainsString('1058', (string) json_encode($hidden));
self::assertStringContainsString('[global 1059]', (string) $shown['outline']);
}
}