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:
@@ -23,8 +23,11 @@ final class Outline
|
|||||||
|
|
||||||
private bool $truncated = false;
|
private bool $truncated = false;
|
||||||
|
|
||||||
/** @param array<string, string> $presetNames Preset ID => name. */
|
/**
|
||||||
public function __construct(private array $presetNames = [])
|
* @param array<string, string> $presetNames Preset ID => name.
|
||||||
|
* @param bool $globalIds Ruling P13: false leaves out the library IDs of global instances.
|
||||||
|
*/
|
||||||
|
public function __construct(private array $presetNames = [], private bool $globalIds = true)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +160,7 @@ final class Outline
|
|||||||
'editable' => $editable,
|
'editable' => $editable,
|
||||||
'depth' => $depth,
|
'depth' => $depth,
|
||||||
'truncated' => false,
|
'truncated' => false,
|
||||||
'global_id' => $global['id'] ?? null,
|
'global_id' => $this->globalIds ? ($global['id'] ?? null) : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ final class DiviIntegration implements Integration
|
|||||||
return 'divi';
|
return 'divi';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling P13: whether this connection may see library IDs (library group visible, library right). */
|
||||||
|
public function libraryIdsVisible(): bool
|
||||||
|
{
|
||||||
|
return $this->access->revealsIds();
|
||||||
|
}
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
return __('Divi', 'wp-lit-mcp');
|
return __('Divi', 'wp-lit-mcp');
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ class LibraryAccess
|
|||||||
return $this->roles->refusal('divi_library');
|
return $this->roles->refusal('divi_library');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling P13: whether library IDs may be named to this connection at all – the library group
|
||||||
|
* is not disabled (P9; "nur lesen" still names them) and the user has the library right
|
||||||
|
* (canList). Otherwise outlines, node-get, write results, text search and the render summary
|
||||||
|
* say "global module" without the entry's ID, as if it were not stored.
|
||||||
|
*/
|
||||||
|
public function revealsIds(): bool
|
||||||
|
{
|
||||||
|
return ($this->visible === null || ($this->visible)()) && $this->canList() === null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Reading one entry: canList() and edit_post on the entry. */
|
/** Reading one entry: canList() and edit_post on the entry. */
|
||||||
public function canRead(WP_Post $item): ?WP_Error
|
public function canRead(WP_Post $item): ?WP_Error
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -130,13 +130,22 @@ final class DiviNodeGetAbility implements Ability
|
|||||||
$version = $block->attrs()['builderVersion'] ?? null;
|
$version = $block->attrs()['builderVersion'] ?? null;
|
||||||
|
|
||||||
$global = NodeSummary::globalRef($block);
|
$global = NodeSummary::globalRef($block);
|
||||||
if ($global !== null) {
|
if ($global !== null && $this->loader->libraryIdsVisible()) {
|
||||||
$global['hint'] = sprintf(
|
$global['hint'] = sprintf(
|
||||||
'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); '
|
'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); '
|
||||||
. 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.',
|
. 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.',
|
||||||
$global['id'],
|
$global['id'],
|
||||||
$global['id']
|
$global['id']
|
||||||
);
|
);
|
||||||
|
} elseif ($global !== null) {
|
||||||
|
// Ruling P13: which entry is shown stays unknown without library access (AttrMap::flatten
|
||||||
|
// already leaves globalModule/globalParent out of attrs).
|
||||||
|
$global = [
|
||||||
|
'id' => null,
|
||||||
|
'block_name' => $global['block_name'],
|
||||||
|
'hint' => 'Globales Modul aus der Divi-Bibliothek – nur lesbar; dieses Konto hat keinen Zugang zur '
|
||||||
|
. 'Divi-Bibliothek.',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ final class DiviOutlineAbility implements Ability
|
|||||||
return DiviErrors::unknownNode($rootId, $page->post);
|
return DiviErrors::unknownNode($rootId, $page->post);
|
||||||
}
|
}
|
||||||
|
|
||||||
$outline = new Outline($this->design->read()->presetNames());
|
$outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible());
|
||||||
$rows = $outline->rows(
|
$rows = $outline->rows(
|
||||||
$page->index,
|
$page->index,
|
||||||
$rootId,
|
$rootId,
|
||||||
@@ -191,8 +191,12 @@ final class DiviOutlineAbility implements Ability
|
|||||||
|
|
||||||
if (in_array(false, array_column($rows, 'editable'), true)) {
|
if (in_array(false, array_column($rows, 'editable'), true)) {
|
||||||
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes '
|
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes '
|
||||||
. 'verschieben, duplizieren oder löschen. Globale Module [global <ID>] im Bibliothekseintrag '
|
. 'verschieben, duplizieren oder löschen. '
|
||||||
. 'bearbeiten: lit-divi-outline mit post_id <ID> – die Änderung wirkt auf alle Seiten.';
|
. ($this->loader->libraryIdsVisible()
|
||||||
|
? 'Globale Module [global <ID>] im Bibliothekseintrag bearbeiten: lit-divi-outline mit post_id '
|
||||||
|
. '<ID> – die Änderung wirkt auf alle Seiten.'
|
||||||
|
// Ruling P13: without library access the entry stays unnamed.
|
||||||
|
: 'Globale Module stammen aus der Divi-Bibliothek, für die dieses Konto keinen Zugang hat.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $hints;
|
return $hints;
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ final class DiviPageLoader
|
|||||||
return $this->library;
|
return $this->library;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling P13: whether the Divi tools may name library IDs (no library access = never). */
|
||||||
|
public function libraryIdsVisible(): bool
|
||||||
|
{
|
||||||
|
return $this->library?->revealsIds() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -417,6 +417,11 @@ final class DiviReplaceTextAbility implements Ability
|
|||||||
*/
|
*/
|
||||||
private function hits(DiviPage $page, TextReplacer $replacer): array
|
private function hits(DiviPage $page, TextReplacer $replacer): array
|
||||||
{
|
{
|
||||||
return $this->search->replace($page, $replacer, $this->writer->composition($page)->cleaner());
|
return $this->search->replace(
|
||||||
|
$page,
|
||||||
|
$replacer,
|
||||||
|
$this->writer->composition($page)->cleaner(),
|
||||||
|
$this->loader->libraryIdsVisible()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,14 +68,20 @@ final class DiviTextSearch
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every hit of $page, computed without changing it.
|
* Every hit of $page, computed without changing it. Without $globalIds (ruling P13: no library
|
||||||
|
* access) the library IDs a global instance stores are no text to find: an opaque node is
|
||||||
|
* matched without globalModule/globalParent, as if they were not stored.
|
||||||
*
|
*
|
||||||
* @return array{count: int, samples: list<array<string, string>>,
|
* @return array{count: int, samples: list<array<string, string>>,
|
||||||
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
* blocks: list<array{Block, array<string, mixed>}>, skipped_nodes: list<array{id: string, reason: string}>}
|
||||||
* @throws InvalidArgumentException When a place would be changed unsafely – the page fails.
|
* @throws InvalidArgumentException When a place would be changed unsafely – the page fails.
|
||||||
*/
|
*/
|
||||||
public function replace(DiviPage $page, TextReplacer $replacer, ValueCleaner $cleaner): array
|
public function replace(
|
||||||
{
|
DiviPage $page,
|
||||||
|
TextReplacer $replacer,
|
||||||
|
ValueCleaner $cleaner,
|
||||||
|
bool $globalIds = true
|
||||||
|
): array {
|
||||||
$count = 0;
|
$count = 0;
|
||||||
$samples = [];
|
$samples = [];
|
||||||
$blocks = [];
|
$blocks = [];
|
||||||
@@ -83,7 +89,8 @@ final class DiviTextSearch
|
|||||||
foreach ($page->index->entries() as $entry) {
|
foreach ($page->index->entries() as $entry) {
|
||||||
$block = $entry['block'];
|
$block = $entry['block'];
|
||||||
if ($block->isDivi() && $block->isOpaque()) {
|
if ($block->isDivi() && $block->isOpaque()) {
|
||||||
if ($replacer->mentions(Serializer::block($block))) {
|
$markup = $globalIds ? Serializer::block($block) : self::withoutLibraryIds($block);
|
||||||
|
if ($replacer->mentions($markup)) {
|
||||||
$skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)];
|
$skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)];
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -112,6 +119,22 @@ final class DiviTextSearch
|
|||||||
return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks, 'skipped_nodes' => $skipped];
|
return ['count' => $count, 'samples' => $samples, 'blocks' => $blocks, 'skipped_nodes' => $skipped];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling P13: $block's markup with every globalModule/globalParent left out, at every depth –
|
||||||
|
* only for matching, never saved.
|
||||||
|
*/
|
||||||
|
private static function withoutLibraryIds(Block $block): string
|
||||||
|
{
|
||||||
|
$attrs = $block->attrs();
|
||||||
|
unset($attrs['globalModule'], $attrs['globalParent']);
|
||||||
|
$markup = '<!-- wp:' . $block->name() . ' ' . Serializer::attributes($attrs) . ' -->';
|
||||||
|
foreach ($block->isVoid() ? [] : $block->parts() as $part) {
|
||||||
|
$markup .= $part instanceof Block ? self::withoutLibraryIds($part) : $part;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $markup . '<!-- /wp:' . $block->name() . ' -->';
|
||||||
|
}
|
||||||
|
|
||||||
/** Why an opaque node is not changed (global modules are edited in the Divi library, Etappe 3). */
|
/** Why an opaque node is not changed (global modules are edited in the Divi library, Etappe 3). */
|
||||||
private static function opaqueReason(Block $block): string
|
private static function opaqueReason(Block $block): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ final class DiviWriter
|
|||||||
return ['text' => null, 'truncated' => false];
|
return ['text' => null, 'truncated' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
$outline = new Outline($this->design->read()->presetNames());
|
$outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible());
|
||||||
|
|
||||||
return $outline->summary($index, array_map('strval', array_keys($sections)));
|
return $outline->summary($index, array_map('strval', array_keys($sections)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\WordPress\Render;
|
namespace LIT\MCP\Integrations\WordPress\Render;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
use LIT\MCP\Abilities\Ability;
|
use LIT\MCP\Abilities\Ability;
|
||||||
use LIT\MCP\Abilities\Annotations;
|
use LIT\MCP\Abilities\Annotations;
|
||||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
@@ -22,8 +23,15 @@ final class PageRenderSummaryAbility implements Ability
|
|||||||
/** At most this many global-module notes per summary. */
|
/** At most this many global-module notes per summary. */
|
||||||
private const GLOBAL_NOTES = 20;
|
private const GLOBAL_NOTES = 20;
|
||||||
|
|
||||||
public function __construct(private ContentSchema $schema, private PageRenderer $renderer)
|
/**
|
||||||
{
|
* @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether global module IDs may be named
|
||||||
|
* (library access of this connection); null = always.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private ContentSchema $schema,
|
||||||
|
private PageRenderer $renderer,
|
||||||
|
private ?Closure $globalIdsVisible = null
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function name(): string
|
public function name(): string
|
||||||
@@ -108,7 +116,16 @@ final class PageRenderSummaryAbility implements Ability
|
|||||||
$notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.';
|
$notes[] = 'Der Eintrag ist passwortgeschützt; Besucher sehen zuerst das Passwortformular.';
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (array_slice(self::globalModuleIds((string) $post->post_content), 0, self::GLOBAL_NOTES) as $id) {
|
$globalIds = self::globalModuleIds((string) $post->post_content);
|
||||||
|
if ($globalIds !== [] && $this->globalIdsVisible !== null && ! ($this->globalIdsVisible)()) {
|
||||||
|
$notes[] = sprintf(
|
||||||
|
'%d %s im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi globale Module '
|
||||||
|
. 'nicht; die Divi-Bibliothek ist für dieses Konto nicht zugänglich).',
|
||||||
|
count($globalIds),
|
||||||
|
count($globalIds) === 1 ? 'globales Modul ist' : 'globale Module sind'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
foreach (array_slice($globalIds, 0, self::GLOBAL_NOTES) as $id) {
|
||||||
$notes[] = sprintf(
|
$notes[] = sprintf(
|
||||||
'Globales Modul %s ist im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi '
|
'Globales Modul %s ist im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi '
|
||||||
. 'globale Module nicht); Inhalt mit lit-divi-outline mit post_id %s lesen.',
|
. 'globale Module nicht); Inhalt mit lit-divi-outline mit post_id %s lesen.',
|
||||||
@@ -116,6 +133,7 @@ final class PageRenderSummaryAbility implements Ability
|
|||||||
$id
|
$id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return array_merge(
|
return array_merge(
|
||||||
['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)],
|
['post_id' => (int) $post->ID, 'url' => (string) get_permalink($post)],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\WordPress;
|
namespace LIT\MCP\Integrations\WordPress;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
use LIT\MCP\Integrations\Integration;
|
use LIT\MCP\Integrations\Integration;
|
||||||
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
|
||||||
@@ -21,8 +22,8 @@ use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility;
|
|||||||
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
|
use LIT\MCP\Integrations\WordPress\Menus\MenuOperations;
|
||||||
use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility;
|
use LIT\MCP\Integrations\WordPress\Menus\MenuSetItemsAbility;
|
||||||
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
|
use LIT\MCP\Integrations\WordPress\Menus\MenuWriter;
|
||||||
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
|
|
||||||
use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility;
|
use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
|
||||||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
|
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
|
||||||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
|
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
@@ -49,10 +50,15 @@ defined('ABSPATH') || exit;
|
|||||||
final class WordPressIntegration implements Integration
|
final class WordPressIntegration implements Integration
|
||||||
{
|
{
|
||||||
/** @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration). */
|
/** @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration). */
|
||||||
|
/**
|
||||||
|
* @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether lit-page-render-summary may name
|
||||||
|
* global module IDs (the Divi integration's library access); null = always.
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ContentSchema $schema,
|
private ContentSchema $schema,
|
||||||
private ChangeLog $log,
|
private ChangeLog $log,
|
||||||
private ?ContentComposer $divi = null
|
private ?ContentComposer $divi = null,
|
||||||
|
private ?Closure $globalIdsVisible = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +105,7 @@ final class WordPressIntegration implements Integration
|
|||||||
new MenuAssignLocationAbility($this->log),
|
new MenuAssignLocationAbility($this->log),
|
||||||
new WpSettingsGetAbility($whitelist),
|
new WpSettingsGetAbility($whitelist),
|
||||||
new WpSettingsUpdateAbility($whitelist, $this->log),
|
new WpSettingsUpdateAbility($whitelist, $this->log),
|
||||||
new PageRenderSummaryAbility($this->schema, new PageRenderer()),
|
new PageRenderSummaryAbility($this->schema, new PageRenderer(), $this->globalIdsVisible),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -105,7 +105,12 @@ final class Plugin
|
|||||||
);
|
);
|
||||||
$registry = new IntegrationRegistry(
|
$registry = new IntegrationRegistry(
|
||||||
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
||||||
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
|
new WordPressIntegration(
|
||||||
|
$schema,
|
||||||
|
$changeLog,
|
||||||
|
$divi->contentComposer($diviWritable),
|
||||||
|
$divi->libraryIdsVisible(...)
|
||||||
|
),
|
||||||
new MediaIntegration($schema, $changeLog, $limits),
|
new MediaIntegration($schema, $changeLog, $limits),
|
||||||
$divi,
|
$divi,
|
||||||
$divi->library()
|
$divi->library()
|
||||||
|
|||||||
@@ -183,4 +183,27 @@ final class OutlineTest extends TestCase
|
|||||||
{
|
{
|
||||||
return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name);
|
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::assertInstanceOf(WP_Error::class, $refusal);
|
||||||
self::assertToolError($refusal, 'not_found');
|
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']);
|
LibraryFixtures::given([], ['edit_posts']);
|
||||||
self::assertToolError(LibraryFixtures::access()->canInsert(), 'forbidden');
|
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;
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
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\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;
|
||||||
@@ -128,7 +129,7 @@ final class DiviReadAbilitiesTest extends TestCase
|
|||||||
public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void
|
public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void
|
||||||
{
|
{
|
||||||
DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
|
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']);
|
$global = $ability->execute(['post_id' => 7, 'id' => 'g0']);
|
||||||
$plain = $ability->execute(['post_id' => 7, 'id' => 's0']);
|
$plain = $ability->execute(['post_id' => 7, 'id' => 's0']);
|
||||||
@@ -140,6 +141,56 @@ final class DiviReadAbilitiesTest extends TestCase
|
|||||||
self::assertNull($plain['global']);
|
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
|
public function testOutlineOfALibraryItem(): void
|
||||||
{
|
{
|
||||||
LibraryFixtures::given([LibraryFixtures::item(1059)]);
|
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\Integrations\Divi\Pages\DiviReplaceTextAbility;
|
||||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||||
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||||
use LIT\MCP\Tests\Unit\TestCase;
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@@ -38,13 +39,19 @@ final class DiviReplaceTextAbilityTest extends TestCase
|
|||||||
private array $failures = [];
|
private array $failures = [];
|
||||||
|
|
||||||
/** @param list<int> $ids Rows of the prefilter query. */
|
/** @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'));
|
$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_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');
|
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
|
||||||
$schema = DiviFixtures::schema();
|
$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 = Mockery::mock(\wpdb::class)->makePartial();
|
||||||
$db->allows('get_col')->andReturnUsing(function (string $sql) use ($ids): array {
|
$db->allows('get_col')->andReturnUsing(function (string $sql) use ($ids): array {
|
||||||
$this->queries[] = $sql;
|
$this->queries[] = $sql;
|
||||||
@@ -420,6 +427,43 @@ final class DiviReplaceTextAbilityTest extends TestCase
|
|||||||
self::assertSame([], $this->saved);
|
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. */
|
/** No effective change (search == replacement, cleaned replacement == match): nothing saved. */
|
||||||
public function testNoEffectiveChangeSavesNothing(): void
|
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\NodeIndex;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||||
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
|
|
||||||
final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase
|
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::assertStringContainsString('nur im Divi-Builder', $client['fields']['changes[0].attrs.module.decoration.attributes']);
|
||||||
self::assertSame([], $this->saved);
|
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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,44 @@ final class PageRenderSummaryAbilityTest extends TestCase
|
|||||||
self::assertStringContainsString('lit-divi-outline mit post_id 1059', $globals[1]);
|
self::assertStringContainsString('lit-divi-outline mit post_id 1059', $globals[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling P13: without library access the note counts global modules instead of naming them. */
|
||||||
|
public function testWithoutLibraryAccessGlobalModulesAreOnlyCounted(): void
|
||||||
|
{
|
||||||
|
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
|
||||||
|
$post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]);
|
||||||
|
Functions\when('get_post')->justReturn($post);
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
|
||||||
|
$renderer = Mockery::mock(PageRenderer::class);
|
||||||
|
$renderer->allows('render')->andReturn('<p>Text</p>');
|
||||||
|
$ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer, static fn (): bool => false);
|
||||||
|
|
||||||
|
$result = $ability->execute(['post_id' => 7]);
|
||||||
|
|
||||||
|
$joined = implode("\n", $result['notes']);
|
||||||
|
self::assertStringContainsString('2 globale Module sind im Auszug nicht enthalten', $joined);
|
||||||
|
self::assertStringNotContainsString('1058', $joined);
|
||||||
|
self::assertStringNotContainsString('1059', $joined);
|
||||||
|
self::assertStringNotContainsString('1058', (string) json_encode($result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling P13: with library access the notes name each entry. */
|
||||||
|
public function testWithLibraryAccessGlobalModulesAreNamed(): void
|
||||||
|
{
|
||||||
|
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
|
||||||
|
$post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]);
|
||||||
|
Functions\when('get_post')->justReturn($post);
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
|
||||||
|
$renderer = Mockery::mock(PageRenderer::class);
|
||||||
|
$renderer->allows('render')->andReturn('<p>Text</p>');
|
||||||
|
$ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer, static fn (): bool => true);
|
||||||
|
|
||||||
|
$joined = implode("\n", $ability->execute(['post_id' => 7])['notes']);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Globales Modul 1058', $joined);
|
||||||
|
}
|
||||||
|
|
||||||
public function testLibraryEntriesAreNoManagedContent(): void
|
public function testLibraryEntriesAreNoManagedContent(): void
|
||||||
{
|
{
|
||||||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout']));
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout']));
|
||||||
|
|||||||
Reference in New Issue
Block a user