Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php
T
janniclas.loosenandClaude Opus 5.5 4b72c2bb42 feat: Divi composer – short/full form to blocks with structure rules, presets and labels
Labels, background_color and preset ids go through the choke point (S13);
ambiguous preset names fail with their ids (S13a); fullwidth + rows is an
error. Carry-ins: control characters stripped before the marker check (S32),
kind override may only tighten, reference alt texts dropped, color check via
VariableRef::typeOf (S33).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 14:15:33 +02:00

426 lines
22 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/S21/S22: every $variable(…)$ value is parsed, strictly checked, cleaned and re-serialized. */
final class VariableRefTest extends TestCase
{
/** @param array<string, array<string, string>>|null $dynamicContent */
private static function ref(?array $dynamicContent = null): VariableRef
{
return new VariableRef(DesignSystemTest::sample(), $dynamicContent);
}
private static function content(string $name, string $settings): string
{
return '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$';
}
/** A JSON "\uXXXX" escape, built from bytes so no tool turns it into the character itself. */
private static function escape(string $hex): string
{
return chr(92) . 'u' . $hex;
}
/** @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' => [
self::content('post_date', '{"before":"<b>Am</b> ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}'),
self::content('post_date', '{"before":"<b>Am</b> ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}'),
],
'custom meta' => [
self::content('custom_meta_telefon-mobil', '{"before":"","after":""}'),
self::content('custom_meta_telefon-mobil', '{"before":"","after":""}'),
],
'theme font' => [
self::content('--et_global_body_font', '{}'),
self::content('--et_global_body_font', '{}'),
],
];
}
/** @dataProvider valid */
public function testReserializesValidReferences(string $input, string $expected): void
{
self::assertSame($expected, self::ref()->canonical($input, ComposeFixture::sanitizer()));
}
/**
* Ruling S21: string settings are cleaned and the cleaned value is re-serialized.
*
* @return array<string, array{string, string}>
*/
public static function cleaned(): array
{
$script = self::escape('003c') . 'script' . self::escape('003e') . 'alert(1)' . self::escape('003c') . '/script'
. self::escape('003e');
return [
'script in before' => [
self::content('post_title', '{"before":"<b>Hi</b><script>alert(1)</script>","after":""}'),
self::content('post_title', '{"before":"<b>Hi</b>","after":""}'),
],
'script in after' => [
self::content('custom_meta_telefon', '{"before":"","after":"<script>x()</script>!"}'),
self::content('custom_meta_telefon', '{"before":"","after":"!"}'),
],
'escaped script in before' => [
self::content('post_title', '{"before":"' . $script . 'Hallo","after":""}'),
self::content('post_title', '{"before":"Hallo","after":""}'),
],
'tags in date format' => [
self::content('post_date', '{"custom_date_format":"<b>d</b>.m"}'),
self::content('post_date', '{"custom_date_format":"d.m"}'),
],
];
}
/** @dataProvider cleaned */
public function testCleansStringSettings(string $input, string $expected): void
{
$canonical = self::ref()->canonical($input, ComposeFixture::sanitizer());
self::assertSame($expected, $canonical);
self::assertStringNotContainsString('<script', $canonical);
}
/**
* 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
{
$end = self::escape('0029') . '$';
return [
'escaped end marker in before' => [self::content('post_title', '{"before":"x' . $end . ' <b>y</b>","after":""}'), 'allein im Wert'],
'escaped end marker in after' => [self::content('post_title', '{"before":"","after":"' . $end . '"}'), 'allein im Wert'],
'escaped end marker in date format' => [self::content('post_date', '{"custom_date_format":"d' . $end . '"}'), 'allein im Wert'],
'escaped end marker in name' => [self::content('post_title' . $end, '{}'), 'Unbekannter Name'],
'select with markup' => [self::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' => [self::content('gvid-nope', '{}'), 'gvid-nope'],
'content gvid as color' => ['$variable({"type":"color","value":{"name":"gvid-s1","settings":{}}})$', 'gvid-s1'],
'color gcid as content' => [self::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',
],
'settings on a global variable' => [self::content('gvid-s1', '{"before":"<b>x</b>"}'), 'before'],
'settings on a theme font' => [self::content('--et_global_heading_font', '{"after":""}'), 'after'],
'unknown settings key' => [self::content('post_title', '{"before":"","foo":"1"}'), 'foo'],
'setting the option lacks' => [self::content('post_featured_image', '{"before":"x"}'), 'before'],
'enable_html' => [self::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' => [self::content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'],
'doubled meta prefix' => [
self::content('custom_meta_custom_meta__edit_lock', '{}'),
'custom_meta_custom_meta__edit_lock',
],
'loop content outside a loop' => [self::content('loop_post_title', '{}'), 'loop_post_title'],
'unknown dynamic content' => [self::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' => [self::content('post_title', '{"before":"$variable(x)$","after":""}'), 'allein im Wert'],
// S28: a marker the sanitizer rebuilds inside a cleaned setting (html(), text()).
'tag-split marker in before' => [self::content('post_title', '{"before":"$variable<x>(y","after":""}'), 'allein im Wert'],
'NUL-split marker in after' => [
self::content('post_title', '{"before":"","after":"$variable' . self::escape('0000') . '(y"}'),
'allein im Wert',
],
'octet-split marker in date format' => [self::content('post_date', '{"custom_date_format":"$variable%41(y"}'), 'allein im Wert'],
// S32: text() keeps control characters (like sanitize_text_field); VariableRef strips them.
'NUL-split marker in date format' => [
self::content('post_date', '{"custom_date_format":"$variable' . self::escape('0000') . '(y"}'),
'allein im Wert',
],
'DEL-split marker in date format' => [
self::content('post_date', '{"custom_date_format":"$variable' . self::escape('007f') . '(y"}'),
'allein im Wert',
],
'nested marker in before' => [self::content('post_title', '{"before":"$variable(","after":""}'), 'allein im Wert'],
'broken json' => ['$variable(kaputt)$', 'JSON'],
'trailing text' => [self::content('post_title', '{}') . ' ', 'allein im Wert'],
'embedded in text' => ['<p>Hi ' . self::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());
}
/** I-1: the escape really is in the input, and nothing with ")$" inside comes out. */
public function testEscapedEndMarkerIsBytesNotTheCharacter(): void
{
$input = self::content('post_title', '{"before":"x' . self::escape('0029') . '$","after":""}');
self::assertStringNotContainsString(')$ ', $input);
self::assertSame(1, substr_count($input, ')$'));
self::assertStringContainsString("\\u0029", $input);
$this->expectException(InvalidArgumentException::class);
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 (
[
self::content('custom_meta_ziel', '{"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());
}
}
}
/** Ruling S22: an injected allow map (Divi's live registry in Task 5) decides names and setting types. */
public function testInjectedDynamicContentMap(): void
{
$ref = self::ref([
'site_title' => ['before' => 'text', 'after' => 'richtext'],
'post_date' => ['date_format' => 'select', 'offset' => 'number'],
'loop_post_title' => [],
'custom_meta_preis' => ['before' => 'text', 'enable_html' => 'yes_no_button'],
]);
$sanitizer = ComposeFixture::sanitizer();
self::assertSame(
self::content('site_title', '{"before":"Site","after":"<em>!</em>"}'),
$ref->canonical(self::content('site_title', '{"before":"<b>Site</b>","after":"<em>!</em>"}'), $sanitizer)
);
self::assertSame(
self::content('post_date', '{"date_format":"d.m.Y","offset":3}'),
$ref->canonical(self::content('post_date', '{"date_format":" d.m.Y","offset":3}'), $sanitizer)
);
self::assertSame(self::content('loop_post_title', '{}'), $ref->canonical(self::content('loop_post_title', '{}'), $sanitizer));
// Names outside the map: custom_meta_ keeps its rule, the rest is unknown.
self::assertSame(
self::content('custom_meta_telefon', '{"before":"<b>T</b>"}'),
$ref->canonical(self::content('custom_meta_telefon', '{"before":"<b>T</b>"}'), $sanitizer)
);
$rejected = [
'post_title' => self::content('post_title', '{}'),
'enable_html' => self::content('custom_meta_preis', '{"enable_html":"on"}'),
'offset' => self::content('post_date', '{"offset":[1]}'),
];
foreach ($rejected as $fragment => $value) {
try {
$ref->canonical($value, $sanitizer);
self::fail('Accepted: ' . $value);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($fragment, $e->getMessage());
}
}
}
/** S28: the split-marker rows really carry no marker before sanitizing. */
public function testSplitMarkerRowsHaveNoRawMarker(): void
{
$rows = self::malicious();
foreach (['tag-split marker in before', 'NUL-split marker in after', 'octet-split marker in date format'] as $row) {
$payload = substr($rows[$row][0], strlen('$variable('));
self::assertStringNotContainsString('$variable(', $payload, $row);
}
self::assertStringContainsString(chr(92) . 'u0000(y', $rows['NUL-split marker in after'][0]);
}
/** Ruling S29: custom_meta_ names and meta-key settings obey META_KEY, map or not. */
public function testMetaKeysObeyTheRuleEvenFromTheMap(): void
{
$ref = self::ref([
'post_meta_key' => ['before' => 'text', 'meta_key' => 'text', 'select_meta_key' => 'select'],
'custom_meta__edit_lock' => ['before' => 'text'],
'loop_post_meta_key' => ['loop_meta_key' => 'text'],
]);
$sanitizer = ComposeFixture::sanitizer();
self::assertSame(
self::content('post_meta_key', '{"meta_key":"telefon","select_meta_key":"custom_meta_fax"}'),
$ref->canonical(self::content('post_meta_key', '{"meta_key":"telefon","select_meta_key":"custom_meta_fax"}'), $sanitizer)
);
self::assertSame(
self::content('post_meta_key', '{"select_meta_key":""}'),
$ref->canonical(self::content('post_meta_key', '{"select_meta_key":""}'), $sanitizer)
);
$rejected = [
self::content('post_meta_key', '{"meta_key":"_wp_page_template"}') => 'meta_key',
self::content('post_meta_key', '{"select_meta_key":"custom_meta__edit_lock"}') => 'select_meta_key',
self::content('post_meta_key', '{"meta_key":"a b"}') => 'meta_key',
self::content('loop_post_meta_key', '{"loop_meta_key":"_secret"}') => 'loop_meta_key',
self::content('custom_meta__edit_lock', '{}') => 'custom_meta__edit_lock',
];
foreach ($rejected as $value => $fragment) {
try {
$ref->canonical($value, $sanitizer);
self::fail('Accepted: ' . $value);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($fragment, $e->getMessage());
}
}
}
/** @return array<string, array{string}> */
public static function derivedMetaKeyBypasses(): array
{
return [
'doubled prefix in the name' => [self::content('custom_meta_custom_meta__edit_lock', '{}')],
'tripled prefix in the name' => [self::content('custom_meta_custom_meta_custom_meta__edit_lock', '{}')],
'prefix rebuilt by the strip' => [self::content('custom_meta_custom_mcustom_meta_eta_telefon', '{}')],
'loop prefix in the name' => [self::content('custom_meta_loop_post_meta_key__edit_lock', '{}')],
'doubled prefix in select_meta_key' => [
self::content('post_meta_key', '{"select_meta_key":"custom_meta_custom_meta__wp_page_template"}'),
],
'doubled prefix in meta_key' => [self::content('post_meta_key', '{"meta_key":"custom_meta_custom_meta__x"}')],
'loop post prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')],
'loop user prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')],
'loop term prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key__secret"}')],
'doubled loop prefix' => [
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'),
],
'mixed loop and meta prefix' => [
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'),
],
'mixed meta and loop prefix' => [
self::content('post_meta_key', '{"select_meta_key":"custom_meta_loop_term_meta_key__secret"}'),
],
'two loop prefixes' => [
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'),
],
];
}
/**
* Ruling S30: Divi derives the meta key with str_replace of every prefix occurrence
* (DynamicContentOptionPostMetaKey.php:232,275; DynamicContentOptionLoopPostMetaKey.php:366-373).
*
* @dataProvider derivedMetaKeyBypasses
*/
public function testMetaKeysAreCheckedAfterDivisDerivation(string $value): void
{
$ref = self::ref([
'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
]);
$this->expectException(InvalidArgumentException::class);
$ref->canonical($value, ComposeFixture::sanitizer());
}
public function testDerivedMetaKeysThatAreFineStayAccepted(): void
{
$ref = self::ref([
'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
]);
$sanitizer = ComposeFixture::sanitizer();
foreach ([
self::content('custom_meta_telefon', '{}'),
self::content('post_meta_key', '{"select_meta_key":"custom_meta_fax"}'),
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_telefon"}'),
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_first_name"}'),
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key_farbe"}'),
self::content(
'loop_post_meta_key',
'{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"telefon"}'
),
] as $value) {
self::assertSame($value, $ref->canonical($value, $sanitizer));
}
}
/** @return array<string, array{array<array-key, mixed>}> */
public static function malformedMaps(): array
{
return [
'setting type is an array' => [['post_title' => ['before' => ['type' => 'text']]]],
'settings not an array' => [['post_title' => 'text']],
'numeric setting key' => [['post_title' => ['text']]],
'numeric name' => [[['before' => 'text']]],
];
}
/**
* Minor-2: a malformed map fails early and clearly.
*
* @dataProvider malformedMaps
* @param array<array-key, mixed> $map
*/
public function testRejectsMalformedMaps(array $map): void
{
$this->expectException(InvalidArgumentException::class);
self::ref($map); // @phpstan-ignore-line
}
/** Ruling S33c: the type of a whole reference, read from its payload (not from a string prefix). */
public function testTypeOfAReference(): void
{
self::assertSame('color', VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'));
self::assertSame('content', VariableRef::typeOf(self::content('post_title', '{}')));
self::assertSame('color', VariableRef::typeOf('$variable({"value":{"name":"gcid-a1b2"},"type":"color"})$'));
self::assertNull(VariableRef::typeOf('#1a1a1a'));
self::assertNull(VariableRef::typeOf('$variable(kaputt)$'));
self::assertNull(VariableRef::typeOf('$variable({"type":"evil","value":{"name":"x"}})$'));
self::assertNull(VariableRef::typeOf('#fff $variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'));
self::assertNull(VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$ '));
}
public function testDetectsReferences(): void
{
self::assertTrue(VariableRef::contains('a $variable( b'));
self::assertFalse(VariableRef::contains('Preis: 5 $'));
}
}