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>
291 lines
13 KiB
PHP
291 lines
13 KiB
PHP
<?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));
|
||
}
|
||
}
|