feat: StoredNodeGuard keeps S7/S26 for nodes moving between posts (E6)
Refuses, for users without unfiltered_html, non-Divi blocks, raw text parts, unreadable opaque blocks, code modules, builder-only paths (also below the state value), strings that are not sanitizer-stable and stored $variable references that VariableRef::canonical() refuses or would change (P2, P3). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||
use stdClass;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* E6 (security audit M1, R2 finding 1, rulings P2/P3): a stored node that moves to another post –
|
||||
* page → page (lit/divi-node-copy), library → page (lit/library-insert), page → library
|
||||
* (lit/library-save) – keeps its bytes. Stored nodes never passed ValueCleaner, and Divi saves bypass
|
||||
* kses (D7), so without this check an editor could carry an administrator's markup into a post of
|
||||
* their own. For users without unfiltered_html the node is REFUSED (never cleaned) when anything in
|
||||
* its subtree – the snapshot and the localAttrs of global instances included (F7) – is
|
||||
* - a block outside the divi/ namespace, or non-whitespace text between blocks (freeform HTML,
|
||||
* Divi 4 shortcodes),
|
||||
* - an opaque block other than a readable divi/global-layout instance (undecodable attributes,
|
||||
* a globalModule reference on a plain block, an instance without a usable blockName/localAttrs),
|
||||
* - a code module,
|
||||
* - a builder-only path (ValueCleaner::guardPath(): own HTML, HTML attributes, custom CSS – S7/S26),
|
||||
* also when the builder-only key sits below the state value,
|
||||
* - a string with control characters (S32), rich text or code that the sanitizer would change
|
||||
* (not kses-stable), a plain-text string with markup that is not kses-stable, or a URL the
|
||||
* sanitizer refuses – routed exactly like ValueCleaner (kindAt()),
|
||||
* - a $variable(…)$ reference VariableRef::canonical() refuses for the target post, or whose
|
||||
* settings canonical() would clean (e.g. before/after with markup such a user may not write).
|
||||
* Users with unfiltered_html may move anything (the same line as the S7 gate). Pure PHP.
|
||||
*/
|
||||
final class StoredNodeGuard
|
||||
{
|
||||
public const CODE_MODULES = ['divi/code', 'divi/fullwidth-code'];
|
||||
|
||||
/** Keys of a breakpoint map entry that are states, no sub-field names. */
|
||||
private const STATE_KEYS = ['value', 'hover', 'sticky'];
|
||||
|
||||
private const BUILDER_ONLY = 'nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html)';
|
||||
|
||||
public function __construct(
|
||||
private ValueCleaner $cleaner,
|
||||
private VariableRef $references,
|
||||
private ValueSanitizer $sanitizer
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return list<string> German problems; [] when $root may move to the target post. */
|
||||
public function problems(Block $root): array
|
||||
{
|
||||
if ($this->sanitizer->allowsUnfilteredHtml()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$problems = [];
|
||||
$this->walk($root, $problems);
|
||||
|
||||
return array_values(array_unique($problems));
|
||||
}
|
||||
|
||||
/** @param list<string> $problems */
|
||||
private function walk(Block $block, array &$problems): void
|
||||
{
|
||||
if (! $block->isDivi()) {
|
||||
$problems[] = sprintf('%s: kein Divi-Block – %s.', $block->name(), self::BUILDER_ONLY);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$attrs = $this->attributesOf($block);
|
||||
if ($attrs === null) {
|
||||
$problems[] = sprintf(
|
||||
'%s: Attribute nicht lesbar (oder globaler Verweis ohne Moduldaten) – %s.',
|
||||
$block->name(),
|
||||
self::BUILDER_ONLY
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$module = GlobalInstance::effectiveName($block);
|
||||
if (in_array($module, self::CODE_MODULES, true)) {
|
||||
$problems[] = sprintf('%s: Code-Modul – %s.', $module, self::BUILDER_ONLY);
|
||||
}
|
||||
|
||||
foreach (AttrMap::flatten($attrs) as $path => $value) {
|
||||
$this->value($module, (string) $path, $value, $problems);
|
||||
}
|
||||
|
||||
foreach ($block->parts() as $part) {
|
||||
if ($part instanceof Block) {
|
||||
$this->walk($part, $problems);
|
||||
} elseif (trim($part) !== '') {
|
||||
$problems[] = sprintf('%s: Freitext zwischen den Blöcken – %s.', $module, self::BUILDER_ONLY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The attributes to check: a plain block's own, an instance's localAttrs (Divi renders them over
|
||||
* the library entry, F7); null for an opaque block that cannot be read that way.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function attributesOf(Block $block): ?array
|
||||
{
|
||||
if (! $block->isOpaque()) {
|
||||
return $block->attrs();
|
||||
}
|
||||
|
||||
$attrs = $block->attrs();
|
||||
$name = GlobalInstance::effectiveName($block);
|
||||
if (
|
||||
$block->name() !== GlobalInstance::BLOCK
|
||||
|| ! str_starts_with($name, 'divi/')
|
||||
|| $name === GlobalInstance::BLOCK
|
||||
|| ! is_string($attrs['globalModule'] ?? null)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$local = $attrs['localAttrs'] ?? [];
|
||||
if ($local instanceof stdClass && get_object_vars($local) === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($local) && ($local === [] || ! array_is_list($local)) ? $local : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One flattened path: the builder-only gate, then every string below it – a breakpoint map's
|
||||
* state values the way AttrWriter hands them to ValueCleaner::clean().
|
||||
*
|
||||
* @param list<string> $problems
|
||||
*/
|
||||
private function value(string $module, string $path, mixed $value, array &$problems): void
|
||||
{
|
||||
if (! $this->allowedPath($path, $problems)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! AttrMap::isBreakpointMap($value)) {
|
||||
$this->node($module, $path, $path, $value, null, null, $problems);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($value as $states) {
|
||||
if ($states instanceof stdClass) {
|
||||
$states = get_object_vars($states);
|
||||
}
|
||||
if (! is_array($states) || array_is_list($states)) {
|
||||
$this->node($module, $path, $path, $states, null, null, $problems);
|
||||
continue;
|
||||
}
|
||||
foreach ($states as $state => $item) {
|
||||
$field = in_array($state, self::STATE_KEYS, true) ? null : (string) $state;
|
||||
$where = $field === null ? $path : $path . '.' . $field;
|
||||
$this->node($module, $path, $where, $item, $field, $field, $problems);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $where $path plus the keys below the state value: builder-only keys at any depth.
|
||||
* @param string|null $subName First key below the state value (ValueCleaner's sub-kind).
|
||||
* @param string|null $key Nearest key above the value.
|
||||
* @param list<string> $problems
|
||||
*/
|
||||
private function node(
|
||||
string $module,
|
||||
string $path,
|
||||
string $where,
|
||||
mixed $value,
|
||||
?string $subName,
|
||||
?string $key,
|
||||
array &$problems
|
||||
): void {
|
||||
if ($where !== $path && ! $this->allowedPath($where, $problems)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value instanceof stdClass) {
|
||||
$value = get_object_vars($value);
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $childKey => $item) {
|
||||
if (is_string($childKey)) {
|
||||
$below = $where . '.' . $childKey;
|
||||
$this->node($module, $path, $below, $item, $subName ?? $childKey, $childKey, $problems);
|
||||
} else {
|
||||
$this->node($module, $path, $where, $item, $subName, $key, $problems);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$problem = $this->stringProblem($module, $path, $value, $subName, $key);
|
||||
if ($problem !== null) {
|
||||
$problems[] = sprintf('%s: %s', $where, $problem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $problems */
|
||||
private function allowedPath(string $path, array &$problems): bool
|
||||
{
|
||||
try {
|
||||
$this->cleaner->guardPath($path);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$problems[] = $e->getMessage();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function stringProblem(string $module, string $path, string $value, ?string $subName, ?string $key): ?string
|
||||
{
|
||||
if (ControlChars::strip($value) !== $value) {
|
||||
return 'Steuerzeichen im Wert – ' . self::BUILDER_ONLY . '.';
|
||||
}
|
||||
|
||||
$kind = $this->cleaner->kindAt($module, $path, $subName, $key);
|
||||
if (VariableRef::contains($value)) {
|
||||
return $this->referenceProblem($value, $kind === ValueKind::Url);
|
||||
}
|
||||
|
||||
$stable = match ($kind) {
|
||||
ValueKind::Html => $this->sanitizer->html($value) === $value,
|
||||
ValueKind::Code => $this->sanitizer->code($value) === $value,
|
||||
ValueKind::Text => ! str_contains($value, '<') || $this->sanitizer->html($value) === $value,
|
||||
ValueKind::Url => trim($value) === '' || $this->sanitizer->url($value) !== '',
|
||||
};
|
||||
|
||||
return $stable ? null : 'Inhalt, den dieses Konto so nicht schreiben darf (HTML, Skript oder Link) – '
|
||||
. self::BUILDER_ONLY . '.';
|
||||
}
|
||||
|
||||
/** Same check as a client reference (VariableRef::canonical()); refused when cleaning would change it. */
|
||||
private function referenceProblem(string $value, bool $url): ?string
|
||||
{
|
||||
try {
|
||||
$canonical = $this->references->canonical($value, $this->sanitizer, $url);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
|
||||
$cleaned = self::normalized(VariableRef::settingsOf($canonical));
|
||||
|
||||
return $cleaned === self::normalized(VariableRef::settingsOf($value))
|
||||
? null
|
||||
: 'Divi-Verweis mit Einstellungen, die dieses Konto nicht schreiben darf – ' . self::BUILDER_ONLY . '.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $settings
|
||||
* @return array<array-key, mixed> Keys sorted, scalars as strings (post_id 839 = "839").
|
||||
*/
|
||||
private static function normalized(array $settings): array
|
||||
{
|
||||
ksort($settings);
|
||||
|
||||
return array_map(static fn (mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $settings);
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,17 @@ final class ValueCleaner
|
||||
*/
|
||||
public function kindOf(string $module, string $path, ?string $subKey = null): ValueKind
|
||||
{
|
||||
return $this->kind($module, $path, $subKey, $subKey);
|
||||
return $this->kindAt($module, $path, $subKey, $subKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind clean() routes a string to that sits below the state value of $path: $subName is
|
||||
* the first key there, $key the nearest key above the string (step 5, like cleanNode()).
|
||||
* StoredNodeGuard checks stored values against exactly this routing.
|
||||
*/
|
||||
public function kindAt(string $module, string $path, ?string $subName, ?string $key): ValueKind
|
||||
{
|
||||
return $this->kind($module, $path, $subName, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,6 +76,25 @@ final class VariableRef
|
||||
return $type === 'color' || $type === 'content' ? $type : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings of $value when it is exactly one reference ([] otherwise). StoredNodeGuard compares
|
||||
* a stored reference with canonical(): different settings mean cleaning would change them.
|
||||
*
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
public static function settingsOf(string $value): array
|
||||
{
|
||||
try {
|
||||
$payload = self::payloadOf($value);
|
||||
} catch (InvalidArgumentException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$settings = $payload['value']['settings'] ?? [];
|
||||
|
||||
return is_array($settings) ? $settings : [];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|stdClass $settings */
|
||||
public static function serialize(string $type, string $name, array|stdClass $settings): string
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
|
||||
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
|
||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||||
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||||
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
||||
@@ -118,6 +119,12 @@ class DiviComposition
|
||||
return new StructureRules($this->modules->catalog());
|
||||
}
|
||||
|
||||
/** E6: the check for stored nodes that move into this composition's entry (same cleaner and references). */
|
||||
public function storedGuard(): StoredNodeGuard
|
||||
{
|
||||
return new StoredNodeGuard($this->cleaner(), $this->references(), $this->sanitizer);
|
||||
}
|
||||
|
||||
public function editor(): TreeEditor
|
||||
{
|
||||
return new TreeEditor($this->rules());
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||||
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* E6 with rulings P2/P3: stored nodes that move to another post are refused (never cleaned) for
|
||||
* users without unfiltered_html when they carry anything such a user could not write through the
|
||||
* Divi tools. The malicious rows use benign probes only (data-probe, a <script> that logs nothing).
|
||||
*/
|
||||
final class StoredNodeGuardTest extends TestCase
|
||||
{
|
||||
private const PROBE_HTML = '<iframe data-probe="1"></iframe>';
|
||||
private const PROBE_SCRIPT = '<script data-probe="1">/* probe */</script>';
|
||||
|
||||
private static function guard(bool $unfiltered = false): StoredNodeGuard
|
||||
{
|
||||
$references = new VariableRef(
|
||||
DiviFixtures::designSystem(),
|
||||
DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())
|
||||
);
|
||||
$sanitizer = ComposeFixture::sanitizer($unfiltered);
|
||||
|
||||
return new StoredNodeGuard(new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer), $references, $sanitizer);
|
||||
}
|
||||
|
||||
private static function block(string $markup): Block
|
||||
{
|
||||
return (new Parser())->parse($markup)->children()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* A void block the way WordPress/Divi serialize it: quotes inside strings (so inside a
|
||||
* $variable(…)$ payload) become " – ruling P2.
|
||||
*
|
||||
* @param array<string, mixed> $attrs
|
||||
*/
|
||||
private static function node(string $name, array $attrs): string
|
||||
{
|
||||
return '<!-- wp:' . $name . ' ' . Serializer::attributes($attrs) . ' /-->';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $settings */
|
||||
private static function ref(string $name, array $settings): string
|
||||
{
|
||||
return VariableRef::MARKER . json_encode(
|
||||
['type' => 'content', 'value' => ['name' => $name, 'settings' => (object) $settings]],
|
||||
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
|
||||
) . VariableRef::END;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function desktop(mixed $value): array
|
||||
{
|
||||
return ['desktop' => ['value' => $value]];
|
||||
}
|
||||
|
||||
private static function text(string $html): string
|
||||
{
|
||||
return self::node('divi/text', ['content' => ['innerContent' => self::desktop($html)]]);
|
||||
}
|
||||
|
||||
public function testBuilderContentWithInstancesAndLinksPasses(): void
|
||||
{
|
||||
$index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')));
|
||||
$link = self::block(self::node('divi/button', ['button' => ['innerContent' => self::desktop([
|
||||
'text' => 'Kontakt',
|
||||
'linkUrl' => self::ref('post_link_url_page', ['post_id' => '839']),
|
||||
])]]));
|
||||
$title = self::block(self::text(self::ref('post_title', ['before' => 'Titel:', 'after' => ''])));
|
||||
|
||||
self::assertFalse($link->isOpaque(), 'the link must really be parsed (P2)');
|
||||
self::assertFalse($title->isOpaque());
|
||||
self::assertSame([], self::guard()->problems($index->find('s0')));
|
||||
self::assertSame([], self::guard()->problems($index->find('g0')));
|
||||
self::assertSame([], self::guard()->problems($link));
|
||||
self::assertSame([], self::guard()->problems($title));
|
||||
self::assertSame([], self::guard()->problems(self::block(self::text('<p>Tom & <strong>Jerry</strong></p>'))));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string}> */
|
||||
public static function libraryEntries(): array
|
||||
{
|
||||
return [
|
||||
'global section' => ['stage3/library-global--globaler-abschnitt.html'],
|
||||
'global module' => ['stage3/library-global--globales-modul.html'],
|
||||
'full page layout' => ['stage3/library--layout-ganze-seite.html'],
|
||||
'section' => ['stage3/library--library-abschnitt.html'],
|
||||
'module' => ['stage3/library--library-modul.html'],
|
||||
'row' => ['stage3/library--library-zeile.html'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider libraryEntries */
|
||||
public function testRealLibraryEntriesPass(string $fixture): void
|
||||
{
|
||||
$document = (new Parser())->parse(DiviFixtures::markup($fixture));
|
||||
|
||||
foreach ($document->children() as $root) {
|
||||
self::assertSame([], self::guard()->problems($root));
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string, 2?: bool}> Markup, part of the problem, parsed opaque. */
|
||||
public static function refused(): array
|
||||
{
|
||||
$section = static fn (string $inner): string => '<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||||
. $inner . '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->';
|
||||
|
||||
return [
|
||||
// P3: builder-only paths (S7/S26).
|
||||
'custom css' => [
|
||||
self::node('divi/text', ['css' => self::desktop(['freeForm' => 'body{display:none}'])]),
|
||||
'"css"',
|
||||
],
|
||||
'custom css of a sub-element' => [
|
||||
self::node('divi/blurb', ['title' => ['css' => self::desktop(['freeForm' => 'a{}'])]]),
|
||||
'title.css',
|
||||
],
|
||||
'html attributes' => [
|
||||
self::node('divi/text', ['module' => ['decoration' => ['attributes' => self::desktop([
|
||||
'attributes' => [['name' => 'data-probe', 'value' => '1']],
|
||||
])]]]),
|
||||
'module.decoration.attributes',
|
||||
],
|
||||
'own html before the module' => [
|
||||
self::node('divi/text', ['module' => ['advanced' => ['html' => self::desktop([
|
||||
'htmlBefore' => self::PROBE_SCRIPT,
|
||||
])]]]),
|
||||
'module.advanced.html',
|
||||
],
|
||||
'own html below a state value' => [
|
||||
self::node('divi/text', ['module' => ['advanced' => self::desktop([
|
||||
'html' => ['htmlBefore' => self::PROBE_SCRIPT],
|
||||
])]]),
|
||||
'module.advanced.html',
|
||||
],
|
||||
// Code module (E6).
|
||||
'code module' => [
|
||||
self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]),
|
||||
'Code-Modul',
|
||||
],
|
||||
'code module deep inside a section' => [$section('<!-- wp:divi/code /-->'), 'Code-Modul'],
|
||||
// P3: non-Divi blocks and raw text parts.
|
||||
'core html block' => [
|
||||
'<!-- wp:html -->' . self::PROBE_HTML . '<!-- /wp:html -->',
|
||||
'kein Divi-Block',
|
||||
],
|
||||
'core block inside a section' => [
|
||||
$section('<!-- wp:paragraph --><p data-probe="1">x</p><!-- /wp:paragraph -->'),
|
||||
'kein Divi-Block',
|
||||
],
|
||||
'raw text between blocks' => [$section(self::PROBE_HTML), 'Freitext'],
|
||||
'divi 4 shortcode between blocks' => [$section('[et_pb_code]probe[/et_pb_code]'), 'Freitext'],
|
||||
// P3: opaque blocks.
|
||||
'undecodable attributes' => ['<!-- wp:divi/text {"content":kaputt} /-->', 'nicht lesbar', true],
|
||||
'global module reference on a plain block' => [
|
||||
self::node('divi/text', ['globalModule' => '5']),
|
||||
'nicht lesbar',
|
||||
true,
|
||||
],
|
||||
'instance without block name' => [
|
||||
self::node('divi/global-layout', ['globalModule' => '5']),
|
||||
'nicht lesbar',
|
||||
true,
|
||||
],
|
||||
'instance with local attributes that are no object' => [
|
||||
self::node('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/text', 'localAttrs' => 'x']),
|
||||
'nicht lesbar',
|
||||
true,
|
||||
],
|
||||
'instance of a code module' => [
|
||||
self::node('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/code']),
|
||||
'Code-Modul',
|
||||
true,
|
||||
],
|
||||
'css in the local attributes of an instance' => [
|
||||
self::node('divi/global-layout', [
|
||||
'globalModule' => '5',
|
||||
'blockName' => 'divi/text',
|
||||
'localAttrs' => ['css' => self::desktop(['freeForm' => 'a{}'])],
|
||||
]),
|
||||
'"css"',
|
||||
true,
|
||||
],
|
||||
'rich text in the local attributes of an instance' => [
|
||||
self::node('divi/global-layout', [
|
||||
'globalModule' => '5',
|
||||
'blockName' => 'divi/text',
|
||||
'localAttrs' => ['content' => ['innerContent' => self::desktop(self::PROBE_HTML)]],
|
||||
]),
|
||||
'content.innerContent',
|
||||
true,
|
||||
],
|
||||
'script in the snapshot of an instance' => [
|
||||
'<!-- wp:divi/global-layout {"globalModule":"5","blockName":"divi/section"} --><!-- wp:divi/row -->'
|
||||
. '<!-- wp:divi/column -->' . self::text(self::PROBE_SCRIPT)
|
||||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/global-layout -->',
|
||||
'content.innerContent',
|
||||
true,
|
||||
],
|
||||
// P3: rich text that is not kses-stable.
|
||||
'probe element in rich text' => [self::text('<p>Hallo</p>' . self::PROBE_HTML), 'content.innerContent'],
|
||||
'script in rich text' => [self::text(self::PROBE_SCRIPT), 'content.innerContent'],
|
||||
'script in a text field' => [
|
||||
self::node('divi/heading', ['title' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]),
|
||||
'title.innerContent',
|
||||
],
|
||||
'script url' => [
|
||||
self::node('divi/button', ['button' => ['innerContent' => self::desktop([
|
||||
'text' => 'Kontakt',
|
||||
'linkUrl' => 'javascript:void(0)',
|
||||
])]]),
|
||||
'button.innerContent',
|
||||
],
|
||||
'control character' => [self::text('<p>a' . chr(1) . 'b</p>'), 'Steuerzeichen'],
|
||||
// Stored $variable references: the same check as client references (VariableRef).
|
||||
'script in before' => [
|
||||
self::text(self::ref('post_title', ['before' => self::PROBE_SCRIPT, 'after' => ''])),
|
||||
'Divi-Verweis mit Einstellungen',
|
||||
],
|
||||
'probe html in after' => [
|
||||
self::text(self::ref('post_title', ['before' => '', 'after' => self::PROBE_HTML])),
|
||||
'Divi-Verweis mit Einstellungen',
|
||||
],
|
||||
'protected meta' => [self::text(self::ref('custom_meta__edit_lock', [])), 'custom_meta__edit_lock'],
|
||||
'enable_html' => [
|
||||
self::text(self::ref('post_meta_key', [
|
||||
'before' => '',
|
||||
'select_meta_key' => 'custom_meta_telefon',
|
||||
'enable_html' => 'on',
|
||||
])),
|
||||
'enable_html',
|
||||
],
|
||||
'reference embedded in text' => [
|
||||
self::text('<p>Hallo ' . self::ref('post_title', []) . '</p>'),
|
||||
'allein im Wert',
|
||||
],
|
||||
'content reference as a link' => [
|
||||
self::node('divi/button', ['button' => ['innerContent' => self::desktop([
|
||||
'linkUrl' => self::ref('post_title', ['before' => self::PROBE_HTML]),
|
||||
])]]),
|
||||
'button.innerContent',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider refused */
|
||||
public function testRefusesWhatTheUserCouldNotWriteForUsersWithoutUnfilteredHtml(
|
||||
string $markup,
|
||||
string $problem,
|
||||
bool $opaque = false
|
||||
): void {
|
||||
$block = self::block($markup);
|
||||
self::assertSame($opaque, $block->isOpaque(), 'the row must parse as intended (P2)');
|
||||
|
||||
$problems = self::guard()->problems($block);
|
||||
|
||||
self::assertNotSame([], $problems);
|
||||
self::assertStringContainsString($problem, implode(' | ', $problems));
|
||||
self::assertSame([], self::guard(true)->problems($block), 'unfiltered_html may carry it');
|
||||
}
|
||||
|
||||
public function testNamesEveryProblemOnceAndLeavesTheNodeUntouched(): void
|
||||
{
|
||||
$markup = '<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||||
. self::node('divi/code', []) . self::node('divi/code', [])
|
||||
. self::text(self::PROBE_SCRIPT)
|
||||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->';
|
||||
$block = self::block($markup);
|
||||
|
||||
$problems = self::guard()->problems($block);
|
||||
|
||||
self::assertCount(2, $problems);
|
||||
self::assertSame($markup, Serializer::block($block));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||||
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||
@@ -16,6 +17,7 @@ use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
@@ -137,4 +139,21 @@ final class DiviCompositionTest extends TestCase
|
||||
self::assertSame([], $composition->warnings()->collect(NodeIndex::build(new Document()), []));
|
||||
self::assertInstanceOf(TreeEditor::class, $composition->editor());
|
||||
}
|
||||
|
||||
public function testStoredGuardSharesTheCleanerAndReferences(): void
|
||||
{
|
||||
$composition = DiviWriterFixture::composition(DiviWriterFixture::designReader())->forPost(7);
|
||||
$code = Block::create('divi/code', ['builderVersion' => '5.2.1']);
|
||||
|
||||
self::assertInstanceOf(StoredNodeGuard::class, $composition->storedGuard());
|
||||
self::assertSame(
|
||||
[],
|
||||
$composition->storedGuard()->problems(Block::create('divi/text', ['builderVersion' => '5.2.1']))
|
||||
);
|
||||
self::assertNotSame([], $composition->storedGuard()->problems($code), 'the editor sanitizer is used');
|
||||
self::assertSame(
|
||||
[],
|
||||
DiviWriterFixture::composition(DiviWriterFixture::designReader(), true)->storedGuard()->problems($code)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user