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:
@@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
|
||||
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
|
||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
||||
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
|
||||
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
|
||||
|
||||
/**
|
||||
* Compose classes wired with the real Divi 5.2.1 sample catalog, the sample design system and
|
||||
* predictable fakes for sanitizing and media (no WordPress). $unfiltered switches the fake
|
||||
* sanitizer between a user with and without unfiltered_html (ruling S7).
|
||||
*/
|
||||
final class ComposeFixture
|
||||
{
|
||||
public static function sanitizer(bool $unfiltered = false): ValueSanitizer
|
||||
{
|
||||
return new class ($unfiltered) implements ValueSanitizer {
|
||||
public function __construct(private bool $unfiltered)
|
||||
{
|
||||
}
|
||||
|
||||
public function text(string $value): string
|
||||
{
|
||||
return trim(strip_tags($value));
|
||||
}
|
||||
|
||||
public function html(string $value): string
|
||||
{
|
||||
return (string) preg_replace('#<script\b[^>]*>.*?</script>#is', '', $value);
|
||||
}
|
||||
|
||||
public function code(string $value): string
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function url(string $value): string
|
||||
{
|
||||
return str_starts_with(strtolower(trim($value)), 'javascript:') ? '' : trim($value);
|
||||
}
|
||||
|
||||
public function allowsUnfilteredHtml(): bool
|
||||
{
|
||||
return $this->unfiltered;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static function media(): MediaResolver
|
||||
{
|
||||
return new class implements MediaResolver {
|
||||
public function image(int $id): ?array
|
||||
{
|
||||
return match ($id) {
|
||||
33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'],
|
||||
12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''],
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static function placeholders(): Placeholders
|
||||
{
|
||||
return new Placeholders(DesignSystemTest::sample());
|
||||
}
|
||||
|
||||
public static function attrWriter(bool $unfiltered = false): AttrWriter
|
||||
{
|
||||
return new AttrWriter(ModuleCatalogTest::sampleCatalog(), self::placeholders(), self::sanitizer($unfiltered));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PlaceholdersTest extends TestCase
|
||||
{
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function placeholders(): array
|
||||
{
|
||||
return [
|
||||
'color alias' => ['{{color:primary}}', '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'],
|
||||
'color label' => ['{{ color : Nachtblau }}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
|
||||
'color id' => ['{{color:gcid-a1b2}}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
|
||||
'variable' => ['{{var:Telefon}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'],
|
||||
'variable id' => ['{{var:gvid-s1}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'],
|
||||
'font' => ['{{font:heading}}', '$variable({"type":"content","value":{"name":"--et_global_heading_font","settings":{}}})$'],
|
||||
'post title' => [
|
||||
'{{post:title}}',
|
||||
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
|
||||
],
|
||||
'featured image' => ['{{post:featured_image}}', '$variable({"type":"content","value":{"name":"post_featured_image","settings":{}}})$'],
|
||||
'custom field' => [
|
||||
'{{field:telefon_mobil}}',
|
||||
'$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$',
|
||||
],
|
||||
'plain text' => ['Hallo Welt', 'Hallo Welt'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider placeholders */
|
||||
public function testResolvesToDiviVariables(string $input, string $expected): void
|
||||
{
|
||||
$resolved = ComposeFixture::placeholders()->resolve($input);
|
||||
|
||||
self::assertSame($expected, $resolved);
|
||||
self::assertSame($expected !== $input, Placeholders::isVariable($resolved));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function invalid(): array
|
||||
{
|
||||
return [
|
||||
'unknown color' => ['{{color:Lila}}', 'Vorhanden: primary, body, Primärfarbe, Fließtextfarbe, Nachtblau.'],
|
||||
'unknown color id' => ['{{color:gcid-nope}}', 'Unbekannte globale Farbe'],
|
||||
'unknown variable' => ['{{var:Fax}}', 'Vorhanden: Radius, Telefon'],
|
||||
'unknown font' => ['{{font:mono}}', 'heading, body'],
|
||||
'unknown post value' => ['{{post:views}}', 'title, excerpt'],
|
||||
'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'],
|
||||
'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'],
|
||||
'embedded' => ['Seite: {{post:title}}', 'allein im Wert'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider invalid */
|
||||
public function testRejectsUnknownNamesWithTheExistingOnes(string $input, string $message): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage($message);
|
||||
|
||||
ComposeFixture::placeholders()->resolve($input);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function ambiguous(): array
|
||||
{
|
||||
return [
|
||||
'color label' => ['{{color:blau}}', 'gcid-b1, gcid-b2'],
|
||||
'variable label' => ['{{var:Info}}', 'gvid-i1, gvid-i2'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling S13a: a label two entries share never silently picks the first one; IDs work directly.
|
||||
*
|
||||
* @dataProvider ambiguous
|
||||
*/
|
||||
public function testAmbiguousLabelsFailWithTheIds(string $input, string $ids): void
|
||||
{
|
||||
$placeholders = new Placeholders(new DesignSystem(
|
||||
[
|
||||
['id' => 'gcid-b1', 'label' => 'Blau', 'value' => '#00f'],
|
||||
['id' => 'gcid-b2', 'label' => 'blau', 'value' => '#00a'],
|
||||
],
|
||||
[
|
||||
['id' => 'gvid-i1', 'type' => 'strings', 'label' => 'Info', 'value' => 'a'],
|
||||
['id' => 'gvid-i2', 'type' => 'strings', 'label' => 'Info', 'value' => 'b'],
|
||||
],
|
||||
[],
|
||||
['heading' => '', 'body' => ''],
|
||||
[]
|
||||
));
|
||||
|
||||
try {
|
||||
$placeholders->resolve($input);
|
||||
self::fail('Ambiguous label accepted.');
|
||||
} catch (InvalidArgumentException $e) {
|
||||
self::assertStringContainsString('mehrdeutig', $e->getMessage());
|
||||
self::assertStringContainsString($ids, $e->getMessage());
|
||||
}
|
||||
|
||||
self::assertStringContainsString('"gcid-b2"', $placeholders->resolve('{{color:gcid-b2}}'));
|
||||
self::assertStringContainsString('"gvid-i2"', $placeholders->resolve('{{var:gvid-i2}}'));
|
||||
}
|
||||
|
||||
public function testColorTypeVariablesBecomeColorReferences(): void
|
||||
{
|
||||
$placeholders = new Placeholders(new DesignSystem(
|
||||
[],
|
||||
[['id' => 'gvid-c1', 'type' => 'colors', 'label' => 'Akzent', 'value' => '#f00']],
|
||||
[],
|
||||
['heading' => '', 'body' => ''],
|
||||
[]
|
||||
));
|
||||
|
||||
self::assertSame(
|
||||
'$variable({"type":"color","value":{"name":"gvid-c1","settings":{}}})$',
|
||||
$placeholders->resolve('{{var:Akzent}}')
|
||||
);
|
||||
}
|
||||
|
||||
public function testResolvesDeepValues(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', 'size' => '20px'],
|
||||
ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px'])
|
||||
);
|
||||
self::assertFalse(Placeholders::isVariable('$variable(kaputt)$'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/** Ruling S1/S2/S12: every $variable(…)$ value is parsed, strictly checked and re-serialized. */
|
||||
final class VariableRefTest extends TestCase
|
||||
{
|
||||
private static function ref(): VariableRef
|
||||
{
|
||||
return new VariableRef(DesignSystemTest::sample());
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function valid(): array
|
||||
{
|
||||
return [
|
||||
'reordered keys' => [
|
||||
'$variable({"value":{"settings":{},"name":"gcid-a1b2"},"type":"color"})$',
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||||
],
|
||||
'empty list settings, no settings' => [
|
||||
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":[]}})$',
|
||||
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$',
|
||||
],
|
||||
'missing settings' => [
|
||||
'$variable({"type":"content","value":{"name":"gvid-r1"}})$',
|
||||
'$variable({"type":"content","value":{"name":"gvid-r1","settings":{}}})$',
|
||||
],
|
||||
'color settings numeric' => [
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$',
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$',
|
||||
],
|
||||
'post date with format and html before' => [
|
||||
'$variable({"type":"content","value":{"name":"post_date","settings":{"before":"<b>Am</b> ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}}})$',
|
||||
'$variable({"type":"content","value":{"name":"post_date","settings":{"before":"<b>Am</b> ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}}})$',
|
||||
],
|
||||
'custom meta' => [
|
||||
'$variable({"type":"content","value":{"name":"custom_meta_telefon-mobil","settings":{"before":"","after":""}}})$',
|
||||
'$variable({"type":"content","value":{"name":"custom_meta_telefon-mobil","settings":{"before":"","after":""}}})$',
|
||||
],
|
||||
'theme font' => [
|
||||
'$variable({"type":"content","value":{"name":"--et_global_body_font","settings":{}}})$',
|
||||
'$variable({"type":"content","value":{"name":"--et_global_body_font","settings":{}}})$',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider valid */
|
||||
public function testReserializesValidReferences(string $input, string $expected): void
|
||||
{
|
||||
self::assertSame($expected, self::ref()->canonical($input, ComposeFixture::sanitizer()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-crafted attacks (preflight §4 "Malicious $variable tests").
|
||||
*
|
||||
* @return array<string, array{string, string}> Value => part of the German error.
|
||||
*/
|
||||
public static function malicious(): array
|
||||
{
|
||||
$content = static fn (string $name, string $settings): string =>
|
||||
'$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$';
|
||||
|
||||
return [
|
||||
'script in before' => [$content('post_title', '{"before":"<script>alert(1)</script>","after":""}'), 'before'],
|
||||
'script in after' => [$content('post_title', '{"before":"","after":"<img src=x onerror=alert(1)><script>x()</script>"}'), 'after'],
|
||||
'escaped script in before' => [
|
||||
$content('post_title', '{"before":"<script>alert(1)</script>","after":""}'),
|
||||
'before',
|
||||
],
|
||||
'tags in date format' => [$content('post_date', '{"date_format":"<b>d</b>"}'), 'date_format'],
|
||||
'type evil' => ['$variable({"type":"evil","value":{"name":"gcid-a1b2","settings":{}}})$', 'Typ'],
|
||||
'unknown gcid' => ['$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$', 'gcid-nope'],
|
||||
'unknown gvid' => [$content('gvid-nope', '{}'), 'gvid-nope'],
|
||||
'content gvid as color' => ['$variable({"type":"color","value":{"name":"gvid-s1","settings":{}}})$', 'gvid-s1'],
|
||||
'color gcid as content' => [$content('gcid-a1b2', '{}'), 'gcid-a1b2'],
|
||||
'name with paren' => ['$variable({"type":"color","value":{"name":"gcid-a1b2)","settings":{}}})$', 'Name'],
|
||||
'name with style breakout' => [
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2;}</style><script>alert(1)</script>","settings":{}}})$',
|
||||
'Name',
|
||||
],
|
||||
'unknown settings key' => [$content('post_title', '{"before":"","foo":"1"}'), 'foo'],
|
||||
'enable_html' => [$content('custom_meta_telefon', '{"before":"","after":"","enable_html":"on"}'), 'enable_html'],
|
||||
'color setting not numeric' => [
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"1;}</style>"}}})$',
|
||||
'opacity',
|
||||
],
|
||||
'protected meta key' => [$content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'],
|
||||
'unknown dynamic content' => [$content('post_meta_key', '{}'), 'post_meta_key'],
|
||||
'extra top-level key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}},"x":1})$', 'x'],
|
||||
'extra value key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{},"x":1}})$', 'x'],
|
||||
'greedy smuggling' => ['$variable({"a":"})$<script>alert(1)</script>"})$', 'allein im Wert'],
|
||||
'nested reference in before' => [
|
||||
$content('post_title', '{"before":"$variable(x)$","after":""}'),
|
||||
'allein im Wert',
|
||||
],
|
||||
'broken json' => ['$variable(kaputt)$', 'JSON'],
|
||||
'trailing text' => [$content('post_title', '{}') . ' ', 'allein im Wert'],
|
||||
'embedded in text' => ['<p>Hi ' . $content('post_title', '{"before":"","after":""}') . '</p>', 'allein im Wert'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider malicious */
|
||||
public function testRejectsMaliciousReferences(string $input, string $message): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage($message);
|
||||
|
||||
self::ref()->canonical($input, ComposeFixture::sanitizer());
|
||||
}
|
||||
|
||||
public function testUrlFieldsTakeContentReferencesWithoutBeforeOrAfter(): void
|
||||
{
|
||||
$ref = self::ref();
|
||||
$link = '$variable({"type":"content","value":{"name":"post_link_url","settings":{}}})$';
|
||||
|
||||
self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true));
|
||||
|
||||
foreach (
|
||||
[
|
||||
'$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$',
|
||||
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
|
||||
] as $value
|
||||
) {
|
||||
try {
|
||||
$ref->canonical($value, ComposeFixture::sanitizer(), true);
|
||||
self::fail('Accepted in a URL field: ' . $value);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
self::assertStringContainsString('URL', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testDetectsReferences(): void
|
||||
{
|
||||
self::assertTrue(VariableRef::contains('a $variable( b'));
|
||||
self::assertFalse(VariableRef::contains('Preis: 5 $'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user