Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.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

409 lines
20 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 InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
final class AttrWriterTest extends TestCase
{
private const HEADING = [
'title' => [
'innerContent' => ['desktop' => ['value' => 'Alt']],
'decoration' => ['font' => ['font' => [
'desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111']],
'phone' => ['value' => ['size' => '22px']],
]]],
],
'builderVersion' => '5.2.1',
];
private const TEXT = [
'content' => ['innerContent' => ['desktop' => ['value' => '<p>Alt</p>']]],
'builderVersion' => '5.2.1',
];
public function testMergesShortAndRawBreakpointsAndKeepsTheRest(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
'title.decoration.font.font' => [
'desktop' => ['value' => ['headingLevel' => 'h3'], 'hover' => ['color' => '{{color:primary}}']],
'tablet' => ['size' => '26px'],
],
'title.innerContent' => ['desktop' => '<b>Neu</b>'],
], 'attrs');
self::assertSame([], $result['errors']);
self::assertSame(
[
'desktop' => [
'value' => ['headingLevel' => 'h3'],
'hover' => ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'],
],
'phone' => ['value' => ['size' => '22px']],
'tablet' => ['value' => ['size' => '26px']],
],
$result['attrs']['title']['decoration']['font']['font']
);
// Heading title is kind "text" in the catalog (ruling S3): tags are stripped.
self::assertSame('Neu', $result['attrs']['title']['innerContent']['desktop']['value']);
self::assertSame('5.2.1', $result['attrs']['builderVersion']);
}
public function testNullRemovesPathsAndStatesAndTopLevelHoverMeansDesktop(): void
{
$writer = ComposeFixture::attrWriter();
$removed = $writer->apply('divi/heading', self::HEADING, ['title.decoration.font.font' => null], 'attrs');
self::assertArrayNotHasKey('decoration', $removed['attrs']['title']);
$phoneGone = $writer->apply('divi/heading', self::HEADING, [
'title.decoration.font.font' => ['phone' => ['value' => null], 'hover' => ['color' => '#000000']],
], 'attrs');
self::assertSame(
['desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111'], 'hover' => ['color' => '#000000']]],
$phoneGone['attrs']['title']['decoration']['font']['font']
);
}
public function testUnknownPathsGetSuggestions(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
'title.decoration.font' => ['desktop' => ['color' => '#fff']],
'title.innerContent' => ['mobile' => 'x'],
], 'changes[0].attrs');
self::assertSame(['title.decoration.font'], $result['invalid_paths']);
self::assertStringContainsString(
'title.decoration.font.font',
$result['errors']['changes[0].attrs.title.decoration.font']
);
self::assertStringContainsString('unbekannter Breakpoint "mobile"', $result['errors']['changes[0].attrs.title.innerContent']);
self::assertSame(self::HEADING, $result['attrs']);
}
public function testSanitizesByPlace(): void
{
$cleaner = ComposeFixture::cleaner();
self::assertSame('', $cleaner->clean('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
self::assertSame('<p>x</p>', $cleaner->clean('divi/text', 'content.innerContent', '<p>x</p><script>y</script>'));
self::assertSame('<script>ok()</script>', $cleaner->clean('divi/code', 'content.innerContent', '<script>ok()</script>'));
self::assertSame('10px', $cleaner->clean('divi/text', 'module.decoration.spacing', '<b>10px</b>'));
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
$cleaner->clean('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
);
self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x']));
}
/**
* Ruling S6: each malicious reference fails and nothing is stored (as an editor without
* unfiltered_html).
*
* @return array<string, array{string, string, array<string, mixed>, array<string, mixed>}>
*/
public static function malicious(): array
{
$cases = [];
foreach (DiviFixtures::maliciousRefs() as $name => [$value]) {
$cases[$name] = ['divi/text', 'content.innerContent', ['desktop' => $value], self::TEXT];
}
$cases['embedded reference in rich text'] = [
'divi/text',
'content.innerContent',
['desktop' => '<p>Hallo $variable({"type":"content","value":{"name":"post_title","settings":{}}})$</p>'],
self::TEXT,
];
$cases['embedded reference deep in a value'] = [
'divi/text',
'module.decoration.background',
['desktop' => ['image' => ['url' => 'https://a.test/$variable({"type":"content","value":{"name":"post_title","settings":{}}})$']]],
self::TEXT,
];
// S28: markers a real sanitizer rebuilds after the raw check (verified in DDEV).
$reference = '({"type":"content","value":{"name":"post_title","settings":{"before":"<script>alert(1)</script>","after":""}}})$';
$cases['tag-split marker in rich text'] = [
'divi/text', 'content.innerContent', ['desktop' => '$variable<x>' . $reference], self::TEXT,
];
$cases['octet-split marker in a text title'] = [
'divi/heading', 'title.innerContent', ['desktop' => '$variable%41' . $reference], self::HEADING,
];
$cases['tag-split marker deep in a value'] = [
'divi/text', 'module.decoration.background', ['desktop' => ['color' => '$variable<x>' . $reference]], self::TEXT,
];
$cases['sanitizer-built valid reference'] = [
'divi/text',
'content.innerContent',
['desktop' => '$variable<x>({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
self::TEXT,
];
$cases['javascript via a reference in a URL field'] = [
'divi/fullwidth-header',
'buttonOne.innerContent',
['desktop' => [
'text' => 'Los',
'linkUrl' => '$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$',
]],
[],
];
$cases['javascript via a reference in a URL key'] = [
'divi/text',
'module.advanced.link',
['desktop' => ['url' => '$variable({"type":"content","value":{"name":"custom_meta_ziel","settings":{"before":"javascript:","after":""}}})$']],
self::TEXT,
];
return $cases;
}
/**
* @dataProvider malicious
* @param array<string, mixed> $map
* @param array<string, mixed> $attrs
*/
public function testRejectsMaliciousReferencesAndStoresNothing(string $module, string $path, array $map, array $attrs): void
{
$result = ComposeFixture::attrWriter()->apply($module, $attrs, [$path => $map], 'attrs');
self::assertArrayHasKey('attrs.' . $path, $result['errors']);
self::assertSame($attrs, $result['attrs']);
}
/** S28: the split-marker rows carry no marker before sanitizing, but the sanitizer rebuilds one. */
public function testSplitMarkersAreRebuiltBySanitizing(): void
{
$sanitizer = ComposeFixture::sanitizer();
$rows = ['tag-split marker in rich text', 'octet-split marker in a text title',
'tag-split marker deep in a value'];
foreach (array_intersect_key(self::malicious(), array_flip($rows)) as $name => [, , $map]) {
$raw = (string) json_encode($map, JSON_UNESCAPED_SLASHES);
$value = is_array($map['desktop']) ? $map['desktop']['color'] : $map['desktop'];
self::assertStringNotContainsString('$variable(', $value, $name);
self::assertStringContainsString('$variable(', $sanitizer->text($value) . $sanitizer->html($value), $name . ' ' . $raw);
}
}
/** Ruling S21: an editor's script in before/after is cleaned out, the reference re-serialized. */
public function testReferenceSettingsAreCleanedForEditors(): void
{
$sent = '$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"<script>alert(1)</script>Titel: "}}})$';
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['content.innerContent' => ['desktop' => $sent]], 'attrs');
self::assertSame([], $result['errors']);
self::assertSame(
'$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"Titel: "}}})$',
$result['attrs']['content']['innerContent']['desktop']['value']
);
}
/** Ruling S7: Divi's own HTML/attribute fields only for users with unfiltered_html. */
public function testHtmlAndAttributeFieldsNeedUnfilteredHtml(): void
{
$changes = [
'module.decoration.attributes' => ['desktop' => ['attributes' => [['name' => 'data-x', 'value' => '1']]]],
'module.advanced.html' => ['desktop' => ['elementType' => 'section']],
];
$editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs');
self::assertSame(self::TEXT, $editor['attrs']);
self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.attributes']);
self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.advanced.html']);
$removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.advanced.html' => null], 'attrs');
self::assertArrayHasKey('attrs.module.advanced.html', $removal['errors']);
$admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs');
self::assertSame([], $admin['errors']);
self::assertSame('section', $admin['attrs']['module']['advanced']['html']['desktop']['value']['elementType']);
self::assertSame('data-x', $admin['attrs']['module']['decoration']['attributes']['desktop']['value']['attributes'][0]['name']);
// Deeper paths and the canvas portal's custom CSS are gated too (fix round 1, M-2).
foreach (['module.advanced.html.htmlBefore', 'module.decoration.attributes.desktop', 'css.advanced.customCss'] as $path) {
try {
ComposeFixture::cleaner()->clean('divi/canvas-portal', $path, 'x');
self::fail('Accepted for an editor: ' . $path);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
}
}
self::assertSame('x', ComposeFixture::cleaner(true)->clean('divi/canvas-portal', 'css.advanced.customCss', 'x'));
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.htmlish', 'x'));
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('nur im Divi-Builder');
ComposeFixture::cleaner()->clean('divi/button', 'module.decoration.attributes', ['attributes' => []]);
}
/** Ruling S13: keys inside value maps are plain identifiers. */
public function testRejectsOddKeysInsideValues(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
'module.decoration.spacing' => ['desktop' => ['<img src=x onerror=1>' => '1px']],
], 'attrs');
self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']);
self::assertSame(self::TEXT, $result['attrs']);
}
public function testRejectsInvalidUtf8(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
'content.innerContent' => ['desktop' => "<p>\xC3\x28</p>"],
], 'attrs');
self::assertStringContainsString('UTF-8', $result['errors']['attrs.content.innerContent']);
}
public function testReferencesFromAnEarlierReadAreReserialized(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
'module.decoration.background' => ['desktop' => [
'color' => '$variable({"value":{"name":"gcid-a1b2","settings":[]},"type":"color"})$',
]],
], 'attrs');
self::assertSame([], $result['errors']);
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
$result['attrs']['module']['decoration']['background']['desktop']['value']['color']
);
}
/**
* Ruling S32a: "<" followed by VT/FF is a literal "<" for strip_tags / sanitize_text_field;
* stripping the control character only after the sanitizer would build a real tag. Controls
* go on the RAW value, so the sanitizer sees – and removes – the tag.
*/
public function testControlCharactersGoBeforeTheSanitizer(): void
{
$writer = ComposeFixture::attrWriter();
$cleaner = ComposeFixture::cleaner();
$img = '<' . chr(11) . 'img src=x onerror=alert(1)>';
$script = '<' . chr(12) . 'script>alert(1)</script>';
// The fake reproduces the WordPress behaviour the attack relies on.
self::assertStringContainsString('<' . chr(11) . 'img', ComposeFixture::sanitizer()->text($img));
foreach ([$img . 'Titel', $script . 'Titel'] as $value) {
$result = $writer->apply('divi/heading', self::HEADING, ['title.innerContent' => ['desktop' => $value]], 'attrs');
self::assertSame([], $result['errors']);
$stored = $result['attrs']['title']['innerContent']['desktop']['value'];
self::assertStringNotContainsString('<', $stored);
$button = $cleaner->clean('divi/button', 'button.innerContent', $value, 'text');
self::assertStringNotContainsString('<', $button);
}
}
/** Ruling S34: value keys are whole identifiers – "$" must not match before a trailing newline. */
public function testValueKeysRejectATrailingNewline(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
'module.decoration.spacing' => ['desktop' => ['padding' => ["top\n" => '1px']]],
], 'attrs');
self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']);
}
/**
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a
* control-split placeholder is the placeholder (resolved and checked), never literal text
* that slipped past the placeholder check. Also deep inside values.
*/
public function testControlCharactersGoBeforePlaceholderResolution(): void
{
$writer = ComposeFixture::attrWriter();
$reference = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) {
$result = $writer->apply(
'divi/heading',
[],
['title.innerContent' => ['desktop' => '{' . $control . '{post:title}}']],
'attrs'
);
self::assertSame([], $result['errors'], (string) ord($control));
self::assertSame($reference, $result['attrs']['title']['innerContent']['desktop']['value']);
// Embedded: refused like "{{post:title}}" inside text.
$embedded = $writer->apply(
'divi/heading',
[],
['title.innerContent' => ['desktop' => 'Hallo {' . $control . '{post:title}}']],
'attrs'
);
self::assertStringContainsString('allein im Wert', $embedded['errors']['attrs.title.innerContent']);
self::assertSame([], $embedded['attrs']);
$deep = $writer->apply(
'divi/button',
[],
['button.innerContent' => ['desktop' => ['text' => "{{post:ti" . $control . "tle}}"]]],
'attrs'
);
self::assertSame([], $deep['errors']);
self::assertSame($reference, $deep['attrs']['button']['innerContent']['desktop']['value']['text']);
}
}
/** Ruling S41: structure attributes are refused whatever the catalog says (also for admins, also null). */
public function testStructureAttributesAreRefused(): void
{
foreach ([
['divi/section', 'module.advanced.type', ['desktop' => 'specialty']],
['divi/section', 'module.advanced.type', null],
['divi/section', 'module.advanced', ['desktop' => ['type' => 'fullwidth']]],
['divi/column', 'module.advanced.specialtyColumns', ['desktop' => '2']],
] as [$module, $path, $map]) {
$result = ComposeFixture::attrWriter(true)->apply($module, [], [$path => $map], 'attrs');
self::assertStringContainsString('Struktur', $result['errors']['attrs.' . $path] ?? '', $module . ' ' . $path);
self::assertSame([], $result['invalid_paths'], $path);
self::assertSame([], $result['attrs'], $path);
}
$column = ComposeFixture::attrWriter()->apply('divi/column', [], ['module.advanced.specialtyColumns' => null], 'attrs');
self::assertStringNotContainsString('section_type', $column['errors']['attrs.module.advanced.specialtyColumns']);
$section = ComposeFixture::attrWriter()->apply('divi/section', [], ['module.advanced.type' => null], 'attrs');
self::assertStringContainsString('section_type', $section['errors']['attrs.module.advanced.type']);
$heading = ComposeFixture::attrWriter()->apply('divi/heading', [], ['module.meta.adminLabel' => ['desktop' => 'x']], 'attrs');
self::assertSame([], $heading['errors'], 'only section type and specialty columns are structure');
}
/** Ruling P16: interactions (setAttribute on the frontend) only with unfiltered_html – set and removal. */
public function testInteractionsNeedUnfilteredHtml(): void
{
$interaction = ['interactions' => [[
'trigger' => 'click',
'effect' => 'addAttribute',
'attributeName' => 'data-probe',
'attributeValue' => '1',
]]];
$changes = ['module.decoration.interactions' => ['desktop' => $interaction]];
$editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs');
self::assertSame(self::TEXT, $editor['attrs']);
self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.interactions']);
$removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.decoration.interactions' => null], 'attrs');
self::assertStringContainsString('nur im Divi-Builder', $removal['errors']['attrs.module.decoration.interactions']);
$admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs');
self::assertSame([], $admin['errors']);
self::assertSame(
'data-probe',
$admin['attrs']['module']['decoration']['interactions']['desktop']['value']['interactions'][0]['attributeName']
);
$adminRemoval = ComposeFixture::attrWriter(true)->apply('divi/text', $admin['attrs'], ['module.decoration.interactions' => null], 'attrs');
self::assertSame([], $adminRemoval['errors']);
self::assertArrayNotHasKey('interactions', $adminRemoval['attrs']['module']['decoration'] ?? []);
}
}