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>
147 lines
7.5 KiB
PHP
147 lines
7.5 KiB
PHP
<?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 $'));
|
|
}
|
|
}
|