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
@@ -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
{