From 48ecc627aa538281a924fc6f51d689d5d1c621b0 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 01:02:25 +0200 Subject: [PATCH] feat: Divi read tools accept library entries (library access, E1/E3) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Abilities/ExposurePolicy.php | 9 ++ src/Integrations/Divi/DiviIntegration.php | 22 +++- .../Divi/Library/LibraryAccess.php | 98 +++++++++++++++ src/Integrations/Divi/Pages/DiviPage.php | 14 ++- .../Divi/Pages/DiviPageLoader.php | 26 +++- src/Integrations/WordPress/PostGuard.php | 8 +- src/Plugin.php | 7 +- tests/Support/LibraryFixtures.php | 118 ++++++++++++++++++ tests/Unit/Abilities/ExposurePolicyTest.php | 10 ++ .../Integrations/Divi/DiviIntegrationTest.php | 21 ++++ .../Divi/Library/LibraryAccessTest.php | 88 +++++++++++++ .../Divi/Pages/DiviPageLoaderTest.php | 50 ++++++++ .../Divi/Pages/DiviReadAbilitiesTest.php | 29 +++++ 13 files changed, 487 insertions(+), 13 deletions(-) create mode 100644 src/Integrations/Divi/Library/LibraryAccess.php create mode 100644 tests/Support/LibraryFixtures.php create mode 100644 tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php index 07aa2f2..a5c8c3c 100644 --- a/src/Abilities/ExposurePolicy.php +++ b/src/Abilities/ExposurePolicy.php @@ -181,6 +181,15 @@ final class ExposurePolicy return isset($this->readonlyGroups[$group]); } + /** + * Whether $group is switched on at all: not disabled as a whole ("nur lesen" still reads). The + * group "Divi-Bibliothek" disabled hides library entries from the Divi read tools (ruling P9). + */ + public function groupEnabled(string $group): bool + { + return $this->groupMode($group) !== self::DISABLED; + } + public function defaultMode(Ability $ability): string { return $ability->isDirect() ? self::DIRECT : self::INDIRECT; diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index f825fd6..80508a7 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -6,8 +6,12 @@ namespace LIT\MCP\Integrations\Divi; use Closure; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Integrations\Divi\BlockTree\Parser; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; +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\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; @@ -39,11 +43,23 @@ final class DiviIntegration implements Integration { private DiviModules $modules; private DesignSystemReader $design; + private LibraryAccess $access; - public function __construct(private ContentSchema $schema, private ChangeLog $log) - { + /** + * @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries + * (group "Divi-Bibliothek" writable); null = always. + * @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group + * "Divi-Bibliothek" not disabled, ruling P9); null = always. + */ + public function __construct( + private ContentSchema $schema, + private ChangeLog $log, + ?Closure $libraryWritable = null, + ?Closure $libraryVisible = null + ) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); + $this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable, $libraryVisible); } public function id(): string @@ -80,7 +96,7 @@ final class DiviIntegration implements Integration public function abilities(): array { - $loader = new DiviPageLoader($this->schema); + $loader = new DiviPageLoader($this->schema, new Parser(), $this->access); $composition = new DiviComposition($this->modules, $this->design); $writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition); diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php new file mode 100644 index 0000000..fba31c5 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -0,0 +1,98 @@ +store; + } + + public function roles(): DiviRoles + { + return $this->roles; + } + + /** + * The library entry $postId after the checks for reading, or their refusal; null when $postId + * names no library entry (the caller goes on with PostGuard). With the library hidden (ruling + * P9), an entry is refused exactly like an ID that does not exist – before any rights check. + */ + public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null + { + $id = PostGuard::id($postId); + $post = $id === null ? null : get_post($id); + if (! $post instanceof WP_Post || $post->post_type !== DiviLibraryStore::POST_TYPE) { + return null; + } + + if ($this->visible !== null && ! ($this->visible)()) { + return PostGuard::notFound((int) $post->ID); + } + + return $this->canRead($post) ?? $post; + } + + /** Listing the library: edit_posts and the Divi role option divi_library. */ + public function canList(): ?WP_Error + { + if (! current_user_can('edit_posts')) { + return Errors::forbidden('Keine Berechtigung, die Divi-Bibliothek zu nutzen.'); + } + + return $this->roles->refusal('divi_library'); + } + + /** Reading one entry: canList() and edit_post on the entry. */ + public function canRead(WP_Post $item): ?WP_Error + { + $refusal = $this->canList(); + if ($refusal !== null) { + return $refusal; + } + + return current_user_can('edit_post', (int) $item->ID) + ? null + : Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID)); + } + + public function isGlobal(WP_Post $item): bool + { + return $this->store->isGlobal((int) $item->ID); + } +} diff --git a/src/Integrations/Divi/Pages/DiviPage.php b/src/Integrations/Divi/Pages/DiviPage.php index 6c4a10a..318f4f4 100644 --- a/src/Integrations/Divi/Pages/DiviPage.php +++ b/src/Integrations/Divi/Pages/DiviPage.php @@ -21,10 +21,17 @@ final class DiviPage public function __construct( public readonly WP_Post $post, public readonly Document $document, - public readonly NodeIndex $index + public readonly NodeIndex $index, + private bool $libraryItem = false ) { } + /** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */ + public function isLibraryItem(): bool + { + return $this->libraryItem; + } + public function treeRev(): string { return Rev::of((string) $this->post->post_content); @@ -40,7 +47,10 @@ final class DiviPage public function editUrl(): string { - return self::builderUrl($this->post); + // A library entry has no public permalink (public: false); Divi's library list opens it here. + return $this->libraryItem + ? (string) get_edit_post_link($this->post, 'raw') + : self::builderUrl($this->post); } /** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */ diff --git a/src/Integrations/Divi/Pages/DiviPageLoader.php b/src/Integrations/Divi/Pages/DiviPageLoader.php index 57ceb6d..5bcd09f 100644 --- a/src/Integrations/Divi/Pages/DiviPageLoader.php +++ b/src/Integrations/Divi/Pages/DiviPageLoader.php @@ -8,31 +8,45 @@ use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup; use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Log; use WP_Error; +use WP_Post; defined('ABSPATH') || exit; /** - * Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the - * block tree. Divi 4 shortcode content and unbalanced markup are refused with a hint – - * spec §3.4: Divi 4 content is kept, never edited. + * Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), every other + * entry with its rights and status via PostGuard (spec §4.5), then the block tree. Divi 4 + * shortcode content and unbalanced markup are refused with a hint – spec §3.4: Divi 4 content + * is kept, never edited. */ final class DiviPageLoader { public const OUTLINE_HINT = 'Aktuellen Stand mit lit/divi-outline (direkt: lit-divi-outline) neu lesen und die ' . 'Änderung mit der neuen tree_rev wiederholen.'; - public function __construct(private ContentSchema $schema, private Parser $parser = new Parser()) + public function __construct( + private ContentSchema $schema, + private Parser $parser = new Parser(), + private ?LibraryAccess $library = null + ) { + } + + /** The library access the Divi tools use, null when library entries are not reachable. */ + public function library(): ?LibraryAccess { + return $this->library; } /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */ public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error { - $post = PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); + // Library entries (E1): only for reading so far, and only with a library access. + $item = $forWrite ? null : $this->library?->item($postId, false); + $post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite); if ($post instanceof WP_Error) { return $post; } @@ -64,7 +78,7 @@ final class DiviPageLoader ); } - return new DiviPage($post, $document, NodeIndex::build($document)); + return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post); } /** The entry loaded for writing and checked by writable() – the refusal of either step. */ diff --git a/src/Integrations/WordPress/PostGuard.php b/src/Integrations/WordPress/PostGuard.php index cf88bf4..1cc8be4 100644 --- a/src/Integrations/WordPress/PostGuard.php +++ b/src/Integrations/WordPress/PostGuard.php @@ -45,7 +45,7 @@ final class PostGuard $post = get_post($id); if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) { - return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id)); + return self::notFound($id); } if (! current_user_can('edit_post', $id)) { @@ -63,6 +63,12 @@ final class PostGuard return $post; } + /** The refusal for an ID that names no entry the tools manage – the same for every such ID. */ + public static function notFound(int $id): WP_Error + { + return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id)); + } + /** * A scheduled entry whose publication time has passed (WordPress' cron missed it) or is * less than a minute away: wp_insert_post() turns "future" into "publish" on any save when diff --git a/src/Plugin.php b/src/Plugin.php index daef929..6747594 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -23,6 +23,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration; +use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\Media\MediaIntegration; use LIT\MCP\Integrations\Pods\PodsFieldSource; @@ -71,9 +72,13 @@ final class Plugin (new ChangeLogCleanup($changeLog, $limits))->register(); $schema = self::contentSchema(); - $divi = new DiviIntegration($schema, $changeLog); $registry = null; $policy = null; + // Ruling P9: with the group "Divi-Bibliothek" disabled, the Divi tools do not see library entries. + $libraryVisible = static function () use (&$policy): bool { + return $policy instanceof ExposurePolicy && $policy->groupEnabled(LibraryAccess::GROUP); + }; + $divi = new DiviIntegration($schema, $changeLog, libraryVisible: $libraryVisible); // Security M2: content-create's divi input follows lit/divi-insert (asked at execute time, // when registry and policy exist), so the Divi group's switches stop every Divi layout write. $diviWritable = static function () use (&$registry, &$policy): bool { diff --git a/tests/Support/LibraryFixtures.php b/tests/Support/LibraryFixtures.php new file mode 100644 index 0000000..df9fd56 --- /dev/null +++ b/tests/Support/LibraryFixtures.php @@ -0,0 +1,118 @@ + 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 $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 $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 $posts + * @param list $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 $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 $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()); + } +} diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php index 7f8b8e1..5951d2f 100644 --- a/tests/Unit/Abilities/ExposurePolicyTest.php +++ b/tests/Unit/Abilities/ExposurePolicyTest.php @@ -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( diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 97bdb75..389e5b0 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -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'); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php new file mode 100644 index 0000000..e76d012 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php @@ -0,0 +1,88 @@ +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)); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php index 5f2b13e..3e9824f 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php @@ -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("\n

Gutenberg

\n"); 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'); + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 4a51280..11f9619 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -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'); + } }