Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php
T
janniclas.loosenandClaude Opus 5.5 9d92808918 fix: interactions are builder-only for users without unfiltered_html (P16)
Divi's frontend applies the interaction effect "Attribute" with setAttribute
(e.g. an onclick handler) and sets cookies; *.decoration.interactions joins
the S7 gate in ValueCleaner, which also covers removals and StoredNodeGuard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 01:54:25 +02:00

302 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 &amp; <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',
],
'interactions (P16)' => [
self::node('divi/text', ['module' => ['decoration' => ['interactions' => self::desktop([
'interactions' => [[
'trigger' => 'click',
'effect' => 'addAttribute',
'attributeName' => 'data-probe',
'attributeValue' => '1',
]],
])]]]),
'module.decoration.interactions',
],
// 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));
}
}