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:
@@ -181,6 +181,15 @@ final class ExposurePolicy
|
|||||||
return isset($this->readonlyGroups[$group]);
|
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
|
public function defaultMode(Ability $ability): string
|
||||||
{
|
{
|
||||||
return $ability->isDirect() ? self::DIRECT : self::INDIRECT;
|
return $ability->isDirect() ? self::DIRECT : self::INDIRECT;
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ namespace LIT\MCP\Integrations\Divi;
|
|||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
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\DesignSystemReader;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
|
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\DiviComposition;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||||||
@@ -39,11 +43,23 @@ final class DiviIntegration implements Integration
|
|||||||
{
|
{
|
||||||
private DiviModules $modules;
|
private DiviModules $modules;
|
||||||
private DesignSystemReader $design;
|
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->modules = new DiviModules();
|
||||||
$this->design = new DesignSystemReader();
|
$this->design = new DesignSystemReader();
|
||||||
|
$this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable, $libraryVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function id(): string
|
public function id(): string
|
||||||
@@ -80,7 +96,7 @@ final class DiviIntegration implements Integration
|
|||||||
|
|
||||||
public function abilities(): array
|
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);
|
$composition = new DiviComposition($this->modules, $this->design);
|
||||||
$writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition);
|
$writer = new DiviWriter($loader, new DiviSaver(), $this->log, $this->design, $composition);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rights on Divi Library entries (spec §4.5, E3 – never laxer than Divi): the library at all needs
|
||||||
|
* edit_posts and the Divi role option divi_library; one entry additionally edit_post on it (Divi's
|
||||||
|
* builder routes do not check that, F9). The Divi tools reach library entries only through
|
||||||
|
* item(); every other ID goes on to PostGuard unchanged.
|
||||||
|
* Not final so abilities can be tested with a mock.
|
||||||
|
*/
|
||||||
|
class LibraryAccess
|
||||||
|
{
|
||||||
|
/** Tool group "Divi-Bibliothek" (Tools tab); its switches gate library entries (E9, ruling P9). */
|
||||||
|
public const GROUP = 'divi-library';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param (Closure(): bool)|null $writable Whether the Tools tab lets this connection write to the
|
||||||
|
* library (group "Divi-Bibliothek" not read-only or disabled); null = always.
|
||||||
|
* @param (Closure(): bool)|null $visible Whether library entries exist for this connection at all
|
||||||
|
* (group "Divi-Bibliothek" not disabled, ruling P9); null = always.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private DiviLibraryStore $store,
|
||||||
|
private DiviRoles $roles,
|
||||||
|
private ?Closure $writable = null,
|
||||||
|
private ?Closure $visible = null
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(): DiviLibraryStore
|
||||||
|
{
|
||||||
|
return $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,10 +21,17 @@ final class DiviPage
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly WP_Post $post,
|
public readonly WP_Post $post,
|
||||||
public readonly Document $document,
|
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
|
public function treeRev(): string
|
||||||
{
|
{
|
||||||
return Rev::of((string) $this->post->post_content);
|
return Rev::of((string) $this->post->post_content);
|
||||||
@@ -40,7 +47,10 @@ final class DiviPage
|
|||||||
|
|
||||||
public function editUrl(): string
|
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). */
|
/** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */
|
||||||
|
|||||||
@@ -8,31 +8,45 @@ use LIT\MCP\Abilities\Errors;
|
|||||||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
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\PostGuard;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
use LIT\MCP\Log;
|
use LIT\MCP\Log;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the
|
* Loads an entry for the Divi tools: Divi Library entries via LibraryAccess (E1/E3), every other
|
||||||
* block tree. Divi 4 shortcode content and unbalanced markup are refused with a hint –
|
* entry with its rights and status via PostGuard (spec §4.5), then the block tree. Divi 4
|
||||||
* spec §3.4: Divi 4 content is kept, never edited.
|
* shortcode content and unbalanced markup are refused with a hint – spec §3.4: Divi 4 content
|
||||||
|
* is kept, never edited.
|
||||||
*/
|
*/
|
||||||
final class DiviPageLoader
|
final class DiviPageLoader
|
||||||
{
|
{
|
||||||
public const OUTLINE_HINT = 'Aktuellen Stand mit lit/divi-outline (direkt: lit-divi-outline) neu lesen und die '
|
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.';
|
. 'Ä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). */
|
/** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
|
||||||
public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
|
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) {
|
if ($post instanceof WP_Error) {
|
||||||
return $post;
|
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. */
|
/** The entry loaded for writing and checked by writable() – the refusal of either step. */
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ final class PostGuard
|
|||||||
|
|
||||||
$post = get_post($id);
|
$post = get_post($id);
|
||||||
if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) {
|
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)) {
|
if (! current_user_can('edit_post', $id)) {
|
||||||
@@ -63,6 +63,12 @@ final class PostGuard
|
|||||||
return $post;
|
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
|
* 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
|
* less than a minute away: wp_insert_post() turns "future" into "publish" on any save when
|
||||||
|
|||||||
+6
-1
@@ -23,6 +23,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
|||||||
use LIT\MCP\Guides\InstructionsBuilder;
|
use LIT\MCP\Guides\InstructionsBuilder;
|
||||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
||||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
use LIT\MCP\Integrations\Media\MediaIntegration;
|
use LIT\MCP\Integrations\Media\MediaIntegration;
|
||||||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||||
@@ -71,9 +72,13 @@ final class Plugin
|
|||||||
(new ChangeLogCleanup($changeLog, $limits))->register();
|
(new ChangeLogCleanup($changeLog, $limits))->register();
|
||||||
|
|
||||||
$schema = self::contentSchema();
|
$schema = self::contentSchema();
|
||||||
$divi = new DiviIntegration($schema, $changeLog);
|
|
||||||
$registry = null;
|
$registry = null;
|
||||||
$policy = 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,
|
// 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.
|
// when registry and policy exist), so the Divi group's switches stop every Divi layout write.
|
||||||
$diviWritable = static function () use (&$registry, &$policy): bool {
|
$diviWritable = static function () use (&$registry, &$policy): bool {
|
||||||
|
|||||||
@@ -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]));
|
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
|
public function testSanitizeValidatesGroupEntries(): void
|
||||||
{
|
{
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ use LIT\MCP\Integrations\Divi\DiviIntegration;
|
|||||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||||
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||||
use LIT\MCP\Tests\Unit\TestCase;
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
final class DiviIntegrationTest extends TestCase
|
final class DiviIntegrationTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -55,4 +57,23 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
self::assertInstanceOf(DiviContentComposer::class, $composer);
|
self::assertInstanceOf(DiviContentComposer::class, $composer);
|
||||||
self::assertFalse($composer->available(), 'no Divi classes in unit tests');
|
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\DiviPage;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
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 LIT\MCP\Tests\Unit\TestCase;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
|
|
||||||
@@ -96,4 +101,49 @@ final class DiviPageLoaderTest extends TestCase
|
|||||||
DiviFixtures::givenPost("<!-- wp:paragraph -->\n<p>Gutenberg</p>\n<!-- /wp:paragraph -->");
|
DiviFixtures::givenPost("<!-- wp:paragraph -->\n<p>Gutenberg</p>\n<!-- /wp:paragraph -->");
|
||||||
self::assertToolError($loader->loadWritable(7), 'unsupported');
|
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\DiviOutlineAbility;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||||
use LIT\MCP\Tests\Unit\TestCase;
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
use stdClass;
|
use stdClass;
|
||||||
@@ -138,4 +139,32 @@ final class DiviReadAbilitiesTest extends TestCase
|
|||||||
self::assertFalse($global['editable']);
|
self::assertFalse($global['editable']);
|
||||||
self::assertNull($plain['global']);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user