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:
2026-09-27 01:42:26 +02:00
co-authored by Claude Opus 5.5
parent b7c714939d
commit f8275ad325
6 changed files with 620 additions and 1 deletions
@@ -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 &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',
],
// 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));
}
}