feat: Divi placeholders, strict $variable references and full-form attribute writer

VariableRef parses, validates and re-serializes every $variable(...)$ value
(S1/S2/S12); AttrWriter routes strings by catalog kind incl. per-subName
composite kinds and key-name URL fallback (S3/S3a), rejects odd value keys
(S13) and Divi HTML/attribute fields without unfiltered_html (S7);
ambiguous design-system labels fail with their ids (S13a); R2 finding
recorded (S5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 13:23:59 +02:00
co-authored by Claude Opus 5.5
parent 395e9644ac
commit 1ea2e16c49
10 changed files with 1417 additions and 0 deletions
@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
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
{
$writer = ComposeFixture::attrWriter();
self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
self::assertSame('<p>x</p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p><script>y</script>'));
self::assertSame('<script>ok()</script>', $writer->cleanValue('divi/code', 'content.innerContent', '<script>ok()</script>'));
self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '<b>10px</b>'));
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
$writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
);
self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x']));
}
/** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */
public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void
{
$writer = ComposeFixture::attrWriter();
// Unknown kind, URL keys deep inside the value.
self::assertSame(
['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]],
$writer->cleanValue('divi/text', 'module.decoration.background', [
'color' => '<i>#fff</i>',
'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']],
])
);
self::assertSame(
['url' => '', 'target' => 'on'],
$writer->cleanValue('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => '<b>on</b>'])
);
self::assertSame(['href' => '', 'src' => '', 'image' => ''], $writer->cleanValue('divi/text', 'module.advanced.link', [
'href' => 'javascript:a()', 'src' => 'javascript:b()', 'image' => 'javascript:c()',
]));
// Rich text keeps markup, text kind strips it, unknown innerContent is text too.
self::assertSame('<p>Hallo <b>Welt</b></p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>Hallo <b>Welt</b></p>'));
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '<h1>Titel</h1>'));
self::assertSame('Los', $writer->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>', 'text'));
// Composite path with per-subName kinds (buttonOne.innerContent: text → text, linkUrl → url).
self::assertSame(
['text' => 'Mehr', 'linkUrl' => ''],
$writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', [
'text' => '<b>Mehr</b>',
'linkUrl' => 'javascript:alert(1)',
])
);
self::assertSame('', $writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', 'javascript:x', 'linkUrl'));
// An explicit sub-kind wins over the key name; the image module's alt is text, src a URL.
self::assertSame(
['useIcon' => 'on', 'src' => ''],
$writer->cleanValue('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x'])
);
self::assertSame(
['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'],
$writer->cleanValue('divi/image', 'image.innerContent', ['src' => 'https://kunde.test/a.jpg', 'alt' => '<b>Team</b>'])
);
// Numbers and booleans are no strings: unchanged.
self::assertSame(['size' => 12, 'on' => true], $writer->cleanValue('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true]));
}
/**
* Ruling S6: each malicious reference fails and nothing is stored.
*
* @return array<string, array{string, string, array<string, mixed>, array<string, mixed>}>
*/
public static function malicious(): array
{
$cases = [];
foreach (VariableRefTest::malicious() 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,
];
$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(true)->apply($module, $attrs, [$path => $map], 'attrs');
self::assertArrayHasKey('attrs.' . $path, $result['errors']);
self::assertSame($attrs, $result['attrs']);
}
public function testEmbeddedReferenceNamesTheRule(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Verweise nur allein im Wert');
ComposeFixture::attrWriter()->cleanValue('divi/text', 'content.innerContent', 'a $variable({}) b');
}
/** 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']);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('nur im Divi-Builder');
ComposeFixture::attrWriter()->cleanValue('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']
);
}
}