feat: Divi read tools accept library entries (library access, E1/E3)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 01:02:25 +02:00
co-authored by Claude Opus 5.5
parent 2700fd615a
commit 48ecc627aa
13 changed files with 487 additions and 13 deletions
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Support;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use Mockery;
use WP_Post;
/**
* The Divi Library of the tests: the real builder entries of tests/Fixtures/divi/stage3 by their
* IDs, a page (post 7), WordPress stubs by post ID, a store answering from ITEMS, Divi roles and
* the library access built from them.
*/
final class LibraryFixtures
{
public const PAGE = 7;
/** @var array<int, array{string, string, bool}> ID => [fixture file, layout type, global]. */
public const ITEMS = [
1054 => ['library--layout-ganze-seite.html', 'layout', false],
1055 => ['library--library-abschnitt.html', 'section', false],
1056 => ['library--library-zeile.html', 'row', false],
1057 => ['library--library-modul.html', 'module', false],
1058 => ['library-global--globaler-abschnitt.html', 'section', true],
1059 => ['library-global--globales-modul.html', 'module', true],
];
/** @param array<string, mixed> $fields */
public static function item(int $id, array $fields = []): WP_Post
{
return new WP_Post(array_merge([
'ID' => $id,
'post_type' => DiviLibraryStore::POST_TYPE,
'post_status' => 'publish',
'post_title' => '[Fixture] ' . $id,
'post_content' => DiviFixtures::markup('stage3/' . self::ITEMS[$id][0]),
], $fields));
}
/** @param array<string, mixed> $fields */
public static function page(string $content, array $fields = []): WP_Post
{
return new WP_Post(array_merge([
'ID' => self::PAGE,
'post_type' => 'page',
'post_status' => 'draft',
'post_title' => 'Start',
'post_content' => $content,
], $fields));
}
/**
* WordPress stubs for $posts (get_post by ID) and a user with every capability but $denied.
*
* @param list<WP_Post> $posts
* @param list<string> $denied
*/
public static function given(array $posts, array $denied = []): void
{
$byId = [];
foreach ($posts as $post) {
$byId[(int) $post->ID] = $post;
}
Functions\when('get_post')->alias(
static fn (mixed $id): ?WP_Post => $byId[(int) ($id instanceof WP_Post ? $id->ID : $id)] ?? null
);
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! in_array($cap, $denied, true)
);
Functions\when('get_post_type_object')->justReturn(null);
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
Functions\when('get_edit_post_link')->alias(static fn (mixed $post): string => sprintf(
'https://kunde.test/wp-admin/post.php?post=%d&action=edit',
$post instanceof WP_Post ? $post->ID : (int) $post
));
Functions\when('add_query_arg')->alias(
static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value
);
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
}
/** A store that answers isGlobal()/layoutType() from ITEMS (no database). */
public static function store(): DiviLibraryStore
{
$store = Mockery::mock(DiviLibraryStore::class)->makePartial();
$store->allows('isGlobal')->andReturnUsing(static fn (int $id): bool => self::ITEMS[$id][2] ?? false);
$store->allows('layoutType')->andReturnUsing(static fn (int $id): ?string => self::ITEMS[$id][1] ?? null);
return $store;
}
/** @param list<string> $deniedRoles Divi role options the user lacks. */
public static function roles(array $deniedRoles = []): DiviRoles
{
return new DiviRoles(static fn (string $cap): bool => ! in_array($cap, $deniedRoles, true));
}
/** @param list<string> $deniedRoles */
public static function access(
array $deniedRoles = [],
bool $writable = true,
?DiviLibraryStore $store = null
): LibraryAccess {
return new LibraryAccess($store ?? self::store(), self::roles($deniedRoles), static fn (): bool => $writable);
}
public static function loader(?LibraryAccess $access = null): DiviPageLoader
{
return new DiviPageLoader(DiviFixtures::schema(), new Parser(), $access ?? self::access());
}
}
@@ -200,6 +200,16 @@ final class ExposurePolicyTest extends TestCase
self::assertSame([], $policy->directNames([$update, $delete, $outline]));
}
/** Ruling P9: only a group disabled as a whole is switched off; "nur lesen" still lets it read. */
public function testGroupEnabledUnlessTheWholeGroupIsDisabled(): void
{
self::assertTrue((new ExposurePolicy())->groupEnabled('divi-library'));
self::assertTrue((new ExposurePolicy(['@readonly:divi-library' => true]))->groupEnabled('divi-library'));
self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupEnabled('divi-library'));
self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupEnabled('divi-library'));
self::assertTrue((new ExposurePolicy(['@group:divi' => 'disabled']))->groupEnabled('divi-library'));
}
public function testSanitizeValidatesGroupEntries(): void
{
self::assertSame(
@@ -9,8 +9,10 @@ use LIT\MCP\Integrations\Divi\DiviIntegration;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;
final class DiviIntegrationTest extends TestCase
{
@@ -55,4 +57,23 @@ final class DiviIntegrationTest extends TestCase
self::assertInstanceOf(DiviContentComposer::class, $composer);
self::assertFalse($composer->available(), 'no Divi classes in unit tests');
}
/** E1/E3: the Divi read tools reach library entries; ruling P9: a hidden library leaves them not found. */
public function testTheReadToolsReachTheLibraryUnlessItIsHidden(): void
{
LibraryFixtures::given([LibraryFixtures::item(1055)]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->andReturn(false);
$outline = static fn (DiviIntegration $divi): mixed => $divi->abilities()[0]->permission(['post_id' => 1055]);
// No Divi in unit tests: DiviRoles allows nothing, so a reachable entry is refused, not missing.
$visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), null, static fn (): bool => true);
self::assertToolError($outline($visible), 'forbidden');
self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'forbidden');
$hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static fn (): bool => false);
$refusal = $outline($hidden);
self::assertInstanceOf(WP_Error::class, $refusal);
self::assertToolError($refusal, 'not_found');
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
final class LibraryAccessTest extends TestCase
{
public function testReadsLibraryItemsAndLeavesOtherIdsToPostGuard(): void
{
$item = LibraryFixtures::item(1055);
LibraryFixtures::given([$item, LibraryFixtures::page('')]);
$access = LibraryFixtures::access();
self::assertSame($item, $access->item(1055, false));
self::assertNull($access->item(LibraryFixtures::PAGE, false), 'a page is PostGuard\'s business');
self::assertNull($access->item(9999, false));
self::assertNull($access->item('abc', false));
self::assertNull($access->canList());
}
public function testReadingNeedsEditPostsTheDiviLibraryRoleAndEditPost(): void
{
$item = LibraryFixtures::item(1055);
LibraryFixtures::given([$item], ['edit_posts']);
self::assertToolError(LibraryFixtures::access()->item(1055, false), 'forbidden');
LibraryFixtures::given([$item]);
self::assertToolError(LibraryFixtures::access(['divi_library'])->item(1055, false), 'forbidden');
LibraryFixtures::given([$item], ['edit_post']);
$refusal = LibraryFixtures::access()->item(1055, false);
self::assertToolError($refusal, 'forbidden');
self::assertStringContainsString('Bibliothekseintrag 1055', $refusal->get_error_message());
}
public function testTrashedItemsCanBeRead(): void
{
$item = LibraryFixtures::item(1055, ['post_status' => 'trash']);
LibraryFixtures::given([$item]);
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1055, false));
}
public function testGlobalFromTheStore(): void
{
$access = LibraryFixtures::access();
self::assertTrue($access->isGlobal(LibraryFixtures::item(1058)));
self::assertFalse($access->isGlobal(LibraryFixtures::item(1055)));
}
public function testRefusalsNameNeitherTitleNorContent(): void
{
$item = LibraryFixtures::item(1055, ['post_status' => 'private', 'post_title' => 'Geheimer Entwurf']);
foreach ([['edit_posts'], ['edit_post']] as $denied) {
LibraryFixtures::given([$item], $denied);
$refusal = LibraryFixtures::access()->item(1055, false);
self::assertToolError($refusal, 'forbidden');
self::assertStringNotContainsString('Geheimer Entwurf', $refusal->get_error_message());
self::assertStringNotContainsString('Geheimer Entwurf', (string) wp_json_encode($refusal->get_error_data()));
}
}
/** Ruling P9: with the group "Divi-Bibliothek" disabled, library IDs read like IDs that do not exist. */
public function testAHiddenLibraryAnswersNotFoundLikeAMissingEntry(): void
{
$item = LibraryFixtures::item(1055, ['post_title' => 'Geheimer Entwurf']);
LibraryFixtures::given([$item, LibraryFixtures::page('')], ['edit_post']);
$hidden = new LibraryAccess(LibraryFixtures::store(), LibraryFixtures::roles(), null, static fn (): bool => false);
foreach ([false, true] as $forWrite) {
$refusal = $hidden->item(1055, $forWrite);
self::assertToolError($refusal, 'not_found');
self::assertStringNotContainsString('Geheimer Entwurf', $refusal->get_error_message());
self::assertStringNotContainsString('Bibliothek', $refusal->get_error_message());
}
self::assertNull($hidden->item(LibraryFixtures::PAGE, false), 'pages stay PostGuard\'s business');
self::assertNull($hidden->item(9999, false));
}
}
@@ -7,6 +7,11 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Support\LibraryFixtures;
use Mockery;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Error;
@@ -96,4 +101,49 @@ final class DiviPageLoaderTest extends TestCase
DiviFixtures::givenPost("<!-- wp:paragraph -->\n<p>Gutenberg</p>\n<!-- /wp:paragraph -->");
self::assertToolError($loader->loadWritable(7), 'unsupported');
}
public function testLoadsALibraryItemForReading(): void
{
LibraryFixtures::given([LibraryFixtures::item(1058)]);
$page = LibraryFixtures::loader()->load(1058, false);
self::assertInstanceOf(DiviPage::class, $page);
self::assertTrue($page->isLibraryItem());
self::assertSame('divi/section', $page->index->find('s0')?->name());
self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $page->editUrl());
}
public function testLibraryItemsNeedTheLibraryAccess(): void
{
LibraryFixtures::given([LibraryFixtures::item(1058)]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->with('et_pb_layout')->andReturn(false);
self::assertToolError((new DiviPageLoader($schema))->load(1058, false), 'not_found');
self::assertToolError(
(new DiviPageLoader($schema, new Parser(), LibraryFixtures::access(['divi_library'])))->load(1058, false),
'forbidden'
);
}
public function testLibraryItemsAreNotWritableYet(): void
{
LibraryFixtures::given([LibraryFixtures::item(1058)]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->with('et_pb_layout')->andReturn(false);
$result = (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access()))->load(1058, true);
self::assertToolError($result, 'not_found');
}
/** Ruling P9: the group "Divi-Bibliothek" disabled hides library entries from the Divi read tools. */
public function testAHiddenLibraryLeavesLibraryItemsNotFound(): void
{
LibraryFixtures::given([LibraryFixtures::item(1058)]);
$hidden = new LibraryAccess(LibraryFixtures::store(), LibraryFixtures::roles(), null, static fn (): bool => false);
self::assertToolError(LibraryFixtures::loader($hidden)->load(1058, false), 'not_found');
}
}
@@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviNodeGetAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviOutlineAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use stdClass;
@@ -138,4 +139,32 @@ final class DiviReadAbilitiesTest extends TestCase
self::assertFalse($global['editable']);
self::assertNull($plain['global']);
}
public function testOutlineOfALibraryItem(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$ability = new DiviOutlineAbility(LibraryFixtures::loader(), $this->design());
$result = $ability->execute(['post_id' => 1059]);
self::assertSame(['m0'], array_column($result['nodes'], 'id'));
self::assertStringStartsWith('m0 text "Globales Modul Öffnungszeiten"', $result['outline']);
self::assertSame('https://kunde.test/wp-admin/post.php?post=1059&action=edit', $result['edit_url']);
}
public function testNodeGetOfALibraryItemAndRefusalsWithoutTheLibraryRight(): void
{
LibraryFixtures::given([LibraryFixtures::item(1057, ['post_status' => 'private', 'post_title' => 'Geheim'])]);
$ability = new DiviNodeGetAbility(LibraryFixtures::loader(), $this->design());
$result = $ability->execute(['post_id' => 1057, 'id' => 'm0']);
self::assertSame('m0', $result['id']);
$denied = new DiviNodeGetAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design());
$refusal = $denied->permission(['post_id' => 1057, 'id' => 'm0']);
self::assertToolError($refusal, 'forbidden');
self::assertStringNotContainsString('Geheim', $refusal->get_error_message());
$outline = new DiviOutlineAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design());
self::assertToolError($outline->execute(['post_id' => 1057]), 'forbidden');
}
}