Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php
T
janniclas.loosenandClaude Opus 5.5 06f13799de fix: full-form internal links follow the {{link:}} target rules (ruling P10)
One PostIdParser for {{link:}} and select_post; post_link_url_<type> needs a
living, readable, non-attachment target of exactly that type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 14:54:51 +02:00

523 lines
25 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\VariableRef;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
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(DiviFixtures::designSystem(), $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":""}'),
],
// S32a: controls go before text(), so "<" + VT/FF is a tag text() removes.
'VT-split tag in date format' => [
self::content('post_date', '{"custom_date_format":"<' . self::escape('000b') . 'img src=x onerror=alert(1)>d.m"}'),
self::content('post_date', '{"custom_date_format":"d.m"}'),
],
'FF-split script in date format' => [
self::content('post_date', '{"custom_date_format":"<' . self::escape('000c') . 'script>alert(1)</script>d.m"}'),
self::content('post_date', '{"custom_date_format":"d.m"}'),
],
'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
{
return DiviFixtures::maliciousRefs();
}
/** @dataProvider malicious */
public function testRejectsMaliciousReferences(string $input, string $message): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage($message);
self::ref()->canonical($input, ComposeFixture::sanitizer());
}
/**
* "$" without /D also matches before a trailing newline: the whole-reference pattern, a meta
* key name and a color setting number must each end with their last character.
*/
public function testATrailingNewlineIsNeverAnAnchorMatch(): void
{
$color = VariableRef::serialize('color', 'gcid-a1b2', []);
$rejects = [
'whole reference' => [$color . "\n", 'Verweise nur allein im Wert'],
'meta key name' => [
VariableRef::serialize('content', "custom_meta_telefon\n", ['before' => '', 'after' => '']),
'Unbekannter Name',
],
'color number' => [VariableRef::serialize('color', 'gcid-a1b2', ['hue' => "5\n"]), 'nur Zahlen'],
];
foreach ($rejects as $label => [$input, $message]) {
try {
self::ref()->canonical($input, ComposeFixture::sanitizer());
self::fail($label . ': accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($message, $e->getMessage(), $label);
}
}
self::assertNull(VariableRef::typeOf($color . "\n"));
}
/** 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 $'));
}
/**
* Ruling S37 (M-1/M-3): map field types come from a filter – untrusted. url → url() (a URL the
* sanitizer empties is refused), select_post → a readable numeric post id, select /
* yes_no_button / number → plain values; every other type is refused.
*/
public function testMapFieldTypesAreRoutedStrictly(): void
{
$ref = self::ref([
'evil' => ['link' => 'url', 'color' => 'color', 'blank' => '', 'flag' => 'yes_no_button', 'n' => 'number'],
'post_link_url_page' => ['post_id' => 'select_post'],
]);
$sanitizer = ComposeFixture::sanitizer();
self::assertSame(
self::content('evil', '{"link":"https://kunde.test/a%20b","flag":"on","n":3}'),
$ref->canonical(self::content('evil', '{"link":" https://kunde.test/a b","flag":"on","n":3}'), $sanitizer)
);
self::assertSame(
self::content('post_link_url_page', '{"post_id":"12"}'),
$ref->canonical(self::content('post_link_url_page', '{"post_id":"12"}'), $sanitizer, true)
);
self::assertSame(
self::content('post_link_url_page', '{"post_id":12}'),
$ref->canonical(self::content('post_link_url_page', '{"post_id":12}'), $sanitizer, true)
);
$rejected = [
'javascript: in a url setting' => [self::content('evil', '{"link":"javascript:alert(1)"}'), 'URL'],
'url setting not a string' => [self::content('evil', '{"link":5}'), 'nur Text'],
'unknown field type' => [self::content('evil', '{"color":"red"}'), 'Feldtyp'],
'empty field type' => [self::content('evil', '{"blank":"x"}'), 'Feldtyp'],
'post slug' => [self::content('post_link_url_page', '{"post_id":"impressum"}'), 'Beitrags-ID'],
'zero post id' => [self::content('post_link_url_page', '{"post_id":0}'), 'Beitrags-ID'],
'negative post id' => [self::content('post_link_url_page', '{"post_id":"-3"}'), 'Beitrags-ID'],
'unreadable post' => [self::content('post_link_url_page', '{"post_id":"666"}'), 'Beitrags-ID'],
];
foreach ($rejected as $case => [$value, $fragment]) {
try {
// URL mode: internal links (post_link_url_*) are allowed only there (E13a).
$ref->canonical($value, $sanitizer, true);
self::fail('Accepted: ' . $case);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($fragment, $e->getMessage(), $case);
}
}
}
/** Ruling P10: a full-form internal link follows the {{link:}} target rules, its type included. */
public function testFullFormInternalLinksFollowTheLinkTargetRules(): void
{
$ref = self::ref([
'post_link_url_page' => ['post_id' => 'select_post'],
'post_link_url_post' => ['post_id' => 'select_post'],
]);
$sanitizer = ComposeFixture::sanitizer();
self::assertSame(
self::content('post_link_url_post', '{"post_id":"840"}'),
$ref->canonical(self::content('post_link_url_post', '{"post_id":"840"}'), $sanitizer, true)
);
self::assertSame(
self::content('post_link_url_post', '{"post_id":840}'),
$ref->canonical(self::content('post_link_url_post', '{"post_id":840}'), $sanitizer, true)
);
$refused = [
'a post is no page' => self::content('post_link_url_page', '{"post_id":"840"}'),
'a post is no page (int)' => self::content('post_link_url_page', '{"post_id":840}'),
'a page is no post' => self::content('post_link_url_post', '{"post_id":"12"}'),
'no link target' => self::content('post_link_url_page', '{"post_id":"666"}'),
'attachment' => self::content('post_link_url_page', '{"post_id":"667"}'),
'overflowing id' => self::content('post_link_url_page', '{"post_id":"99999999999999999999"}'),
'padded id' => self::content('post_link_url_page', '{"post_id":"0840"}'),
'float id' => self::content('post_link_url_page', '{"post_id":12.0}'),
'bool id' => self::content('post_link_url_page', '{"post_id":true}'),
];
foreach ($refused as $case => $value) {
try {
$ref->canonical($value, $sanitizer, true);
self::fail($case . ' was accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Beitrags-ID', $e->getMessage(), $case);
}
}
}
/** Ruling S37/P10: select_post outside an internal link stays "a post the user may read", of any type. */
public function testOtherPostSettingsOnlyNeedAReadablePost(): void
{
$ref = self::ref(['evil' => ['p' => 'select_post']]);
$sanitizer = ComposeFixture::sanitizer();
self::assertSame(
self::content('evil', '{"p":"840"}'),
$ref->canonical(self::content('evil', '{"p":"840"}'), $sanitizer)
);
foreach (['"666"', '"0840"', '"99999999999999999999"'] as $id) {
try {
$ref->canonical(self::content('evil', '{"p":' . $id . '}'), $sanitizer);
self::fail($id . ' was accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Beitrags-ID', $e->getMessage(), $id);
}
}
}
/** E13a: an internal link (post_link_url_<type>) is a URL – refused in every other field. */
public function testInternalLinksOnlyInUrlFields(): void
{
$ref = self::ref(['post_link_url_page' => ['post_id' => 'select_post']]);
$link = self::content('post_link_url_page', '{"post_id":"12"}');
self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true));
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('nur in Link- und URL-Feldern');
$ref->canonical($link, ComposeFixture::sanitizer());
}
}