Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/ValueCleanerTest.php
T

354 lines
18 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\ValueCleaner;
use LIT\MCP\Integrations\Divi\Compose\ValueKind;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
/** The choke point (ValueCleaner): tests moved from AttrWriterTest with clean()/kindOf() (H1). */
final class ValueCleanerTest extends TestCase
{
private static function loopCleaner(bool $listUsers, bool $readPrivate): ValueCleaner
{
return new ValueCleaner(
DiviFixtures::catalog(),
ComposeFixture::references(),
ComposeFixture::sanitizer(false, $listUsers, $readPrivate)
);
}
/** @return array<string, array{array<string, mixed>}> Loops that read private posts or user data (audit L1) */
public static function riskyLoops(): array
{
$types = static fn (string ...$values): array => array_map(
static fn (string $value): array => ['value' => $value, 'label' => $value],
$values
);
$post = ['enable' => 'on', 'queryType' => 'post_types'];
return [
'no subTypes' => [$post],
'empty subTypes' => [$post + ['subTypes' => []]],
'queryType missing, empty subTypes' => [['enable' => 'on', 'subTypes' => []]],
'any' => [$post + ['subTypes' => $types('any')]],
'attachment among others' => [$post + ['subTypes' => $types('post', 'attachment')]],
'only unknown types (Divi falls back to any)' => [$post + ['subTypes' => $types('gibtesnicht')]],
'comma string with any' => [$post + ['subTypes' => 'post, ANY']],
'queryType null (isset: post_types), no subTypes' => [['enable' => 'on', 'queryType' => null]],
'subTypes null' => [$post + ['subTypes' => null]],
'entries without value' => [$post + ['subTypes' => [['label' => 'x']]]],
];
}
/**
* @dataProvider riskyLoops
* @param array<string, mixed> $loop
*/
public function testPostLoopsThatMayShowPrivatePostsNeedReadPrivatePosts(array $loop): void
{
try {
self::loopCleaner(true, false)->guardLoop('module.advanced.loop', $loop);
self::fail('expected a refusal without read_private_posts');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('read_private_posts', $e->getMessage());
}
self::loopCleaner(false, true)->guardLoop('module.advanced.loop', $loop);
$this->addToAssertionCount(1);
}
public function testUserLoopsNeedListUsersAlsoUnderDiviAliasAndSanitizeKey(): void
{
foreach (['user_roles', 'users', ' User_Roles '] as $queryType) {
$loop = ['enable' => 'on', 'queryType' => $queryType];
try {
self::loopCleaner(false, true)->guardLoop('module.advanced.loop', $loop);
self::fail('expected a refusal without list_users: ' . $queryType);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('list_users', $e->getMessage());
}
self::loopCleaner(true, false)->guardLoop('module.advanced.loop', $loop);
}
$this->addToAssertionCount(3);
}
public function testSafeLoopsPassWithoutTheExtraRights(): void
{
$cleaner = self::loopCleaner(false, false);
$on = ['enable' => 'on'];
foreach ([
$on + ['queryType' => 'post_types', 'subTypes' => [['value' => 'post'], ['value' => 'page']]],
$on + ['queryType' => 'post_types', 'subTypes' => 'post'],
$on + ['queryType' => 'post_taxonomies', 'subTypes' => []],
$on + ['queryType' => 'menus'],
$on + ['queryType' => 'current_page'],
['enable' => 'off', 'queryType' => 'user_roles'],
['queryType' => 'post_types'],
] as $loop) {
$cleaner->guardLoop('module.advanced.loop', $loop);
}
$cleaner->guardLoop('module.advanced.link', 'irrelevant');
$this->addToAssertionCount(1);
}
/** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */
public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void
{
$cleaner = ComposeFixture::cleaner();
// Unknown kind, URL keys deep inside the value.
self::assertSame(
['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]],
$cleaner->clean('divi/text', 'module.decoration.background', [
'color' => '<i>#fff</i>',
'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']],
])
);
self::assertSame(
['url' => '', 'target' => 'on'],
$cleaner->clean('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => '<b>on</b>'])
);
self::assertSame(['href' => '', 'src' => '', 'image' => ''], $cleaner->clean('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>', $cleaner->clean('divi/text', 'content.innerContent', '<p>Hallo <b>Welt</b></p>'));
self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', '<h1>Titel</h1>'));
self::assertSame('Los', $cleaner->clean('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' => ''],
$cleaner->clean('divi/fullwidth-header', 'buttonOne.innerContent', [
'text' => '<b>Mehr</b>',
'linkUrl' => 'javascript:alert(1)',
])
);
self::assertSame('', $cleaner->clean('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' => ''],
$cleaner->clean('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x'])
);
self::assertSame(
['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'],
$cleaner->clean('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], $cleaner->clean('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true]));
}
public function testEmbeddedReferenceNamesTheRule(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Verweise nur allein im Wert');
ComposeFixture::cleaner()->clean('divi/text', 'content.innerContent', 'a $variable({}) b');
}
/** Ruling S26: every free-form custom CSS path (css.*) is builder-only for editors, by path shape. */
public function testCustomCssPathsNeedUnfilteredHtml(): void
{
$css = ['mainElement' => 'color:red;} body{display:none'];
foreach (['css', 'css.desktop', 'css.advanced.customCss', 'css.advanced.mainElement', 'title.css'] as $path) {
try {
ComposeFixture::cleaner()->clean('divi/text', $path, $css);
self::fail('Accepted for an editor: ' . $path);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
}
self::assertSame($css, ComposeFixture::cleaner(true)->clean('divi/text', $path, $css));
}
// Only a whole "css" segment counts.
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.cssClass', 'x'));
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.cssish', 'x'));
}
/** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */
public function testUnknownContentInnerContentIsRichText(): void
{
self::assertSame('unknown', DiviFixtures::catalog()->pathKind('divi/blurb', 'content.innerContent'));
self::assertSame('<p>Hallo</p>', ComposeFixture::cleaner()->clean('divi/blurb', 'content.innerContent', '<p>Hallo</p><script>x()</script>'));
// Other unknown innerContent stays text.
self::assertSame('Los', ComposeFixture::cleaner()->clean('divi/button', 'button.innerContent', '<b>Los</b>'));
}
/** kindOf() names the routing clean() applies (lit/divi-replace-text skips url/code). */
public function testKindOfNamesTheRouting(): void
{
$cleaner = ComposeFixture::cleaner();
self::assertSame(ValueKind::Html, $cleaner->kindOf('divi/text', 'content.innerContent'));
self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/heading', 'title.innerContent'));
self::assertSame(ValueKind::Code, $cleaner->kindOf('divi/code', 'content.innerContent'));
self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text'));
self::assertSame(ValueKind::Url, $cleaner->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl'));
self::assertSame(ValueKind::Text, $cleaner->kindOf('divi/image', 'image.innerContent', 'alt'));
self::assertSame(ValueKind::Url, $cleaner->kindOf('divi/image', 'image.innerContent', 'src'));
}
/** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */
public function testKeysBelowScalarKindsAndUrlPathSegments(): void
{
$cleaner = ComposeFixture::cleaner();
self::assertSame(
['url' => '', 'text' => 'a'],
$cleaner->clean('divi/heading', 'title.innerContent', ['url' => 'javascript:x', 'text' => '<b>a</b>'])
);
self::assertSame('', $cleaner->clean('divi/contact-form', 'success.advanced.redirectUrl', 'javascript:alert(1)'));
self::assertSame('', $cleaner->clean('divi/text', 'module.advanced.imageUrl', 'javascript:alert(1)'));
self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', '<b>Titel</b>'));
}
/** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */
public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void
{
$cleaner = ComposeFixture::cleaner();
self::assertSame("a\tb\n", $cleaner->clean('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f"));
self::assertSame("<p>a\nb\r</p>", $cleaner->clean('divi/text', 'content.innerContent', "<p>a\x0b\nb\r\x08</p>"));
self::assertSame('Titel', $cleaner->clean('divi/heading', 'title.innerContent', "Ti\x00tel\x7f"));
// S32a: controls go on the RAW value, so a control-split marker IS a marker: the value goes
// through canonical() like any reference – validated, cleaned (S21) and re-serialized.
$tail = '({"type":"content","value":{"name":"post_title","settings":{"before":"<script>x()</script>","after":""}}})$';
$canonical = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
$targets = [
'text' => ['divi/heading', 'title.innerContent'],
'html' => ['divi/text', 'content.innerContent'],
'code' => ['divi/code', 'content.innerContent'],
];
foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) {
$value = '$variable' . $control . $tail;
// The fake keeps them like sanitize_text_field does: the production code handles it.
self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text('$variable' . $control . '(x'));
foreach ($targets as $kind => [$module, $path]) {
self::assertSame($canonical, $cleaner->clean($module, $path, $value), $kind . ' ' . ord($control));
}
// Embedded in text it is an embedded reference: refused.
try {
$cleaner->clean('divi/heading', 'title.innerContent', 'Hallo $variable' . $control . $tail);
self::fail('embedded control-split reference accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('allein im Wert', $e->getMessage());
}
}
}
/** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */
public function testKindOverrideMayOnlyTighten(): void
{
$cleaner = ComposeFixture::cleaner();
self::assertSame('x', $cleaner->clean('divi/text', 'content.innerContent', '<p>x</p>', null, ValueKind::Text));
self::assertSame('x', $cleaner->clean('divi/code', 'content.innerContent', '<b>x</b>', null, ValueKind::Text));
self::assertSame('<p>x</p>', $cleaner->clean('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, ValueKind::Html));
self::assertSame('Los', $cleaner->clean('divi/heading', 'title.innerContent', '<b>Los</b>', null, ValueKind::Text));
self::assertSame('/a', $cleaner->clean('divi/button', 'button.innerContent', '/a', 'linkUrl', ValueKind::Url));
$weaker = [
'html over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Html],
'code over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Code],
'code over html' => ['divi/text', 'content.innerContent', null, ValueKind::Code],
'url over text' => ['divi/heading', 'title.innerContent', null, ValueKind::Url],
'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', ValueKind::Text],
'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', ValueKind::Html],
];
foreach ($weaker as $name => [$module, $path, $sub, $kind]) {
try {
$cleaner->clean($module, $path, 'x', $sub, $kind);
self::fail($name . ' was accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Feldart', $e->getMessage(), $name);
}
}
}
/** D10: replace-text inserts only into text/html places, cleaned by the routed kind; URL and code stay. */
public function testTextReplacementTouchesOnlyTextAndHtml(): void
{
$cleaner = ComposeFixture::cleaner();
self::assertSame('<p>x</p>', ($cleaner->textReplacementFor('divi/text'))('content.innerContent', null, '<p>x</p><script>y</script>'));
self::assertSame('Los', ($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'text', '<b>Los</b>'));
self::assertNull(($cleaner->textReplacementFor('divi/button'))('button.innerContent', 'linkUrl', 'https://x.test'));
self::assertNull(($cleaner->textReplacementFor('divi/code'))('content.innerContent', null, 'x'));
}
/**
* Ruling P16: Divi's interactions ("Attribute" effect → setAttribute on the frontend, Cookie,
* Preset) are builder-only like HTML attributes, at every depth.
*/
public function testInteractionPathsNeedUnfilteredHtml(): void
{
$value = ['interactions' => [[
'trigger' => 'click',
'effect' => 'addAttribute',
'attributeName' => 'data-probe',
'attributeValue' => '1',
]]];
$paths = [
'module.decoration.interactions',
'module.decoration.interactions.desktop',
'title.decoration.interactions',
'decoration.interactions',
];
foreach ($paths as $path) {
try {
ComposeFixture::cleaner()->clean('divi/text', $path, $value);
self::fail('Accepted for an editor: ' . $path);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
}
self::assertSame($value, ComposeFixture::cleaner(true)->clean('divi/text', $path, $value));
}
// Only a whole "interactions" segment below "decoration" counts.
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.decoration.interactionsish', 'x'));
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.interactions', 'x'));
}
/** Ruling P19: the Lottie source (lottie-web runs expressions with eval) is builder-only, at every depth. */
public function testLottieSourceNeedsUnfilteredHtml(): void
{
$value = ['src' => 'https://probe.example/anim.json', 'loop' => 'on'];
$paths = ['lottie.innerContent', 'lottie.innerContent.desktop', 'lottie.innerContent.desktop.value.src'];
foreach ($paths as $path) {
try {
ComposeFixture::cleaner()->clean('divi/lottie', $path, $value);
self::fail('Accepted for an editor: ' . $path);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
}
self::assertSame($value, ComposeFixture::cleaner(true)->clean('divi/lottie', $path, $value));
}
// The size of the animation and look-alike paths stay open.
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/lottie', 'lottie.advanced.width', 'x'));
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/lottie', 'lottie.innerContentish', 'x'));
}
/** Design-system ids (preset ids) are no client text, but they pass the choke point unchanged (ruling S13). */
public function testKeepsIdAcceptsPlainIdsAndRefusesEverythingTheCleanerChanges(): void
{
$cleaner = ComposeFixture::cleaner();
self::assertTrue($cleaner->keepsId('3gbmxlurey'));
self::assertTrue($cleaner->keepsId('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'));
self::assertFalse($cleaner->keepsId('<b>p</b>'), 'tags would be stripped');
self::assertFalse($cleaner->keepsId('p%41x'), 'octets would be stripped');
self::assertFalse($cleaner->keepsId("p\x01"), 'control characters would be stripped');
self::assertFalse($cleaner->keepsId(ComposeFixture::REFERENCE_ALT), 'an id is never a $variable reference');
}
}