Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php
T
janniclas.loosenandClaude Opus 5.5 62ce99e558 refactor: ValueCleaner is the named choke point (H1)
The choke point moves out of AttrWriter into ValueCleaner (catalog, the
composition's VariableRef, sanitizer): clean() (was AttrWriter::cleanValue),
kindOf(), guardPath() and the private cleanNode/kind/unknownKind/tightens with
VALUE_KEY, URL_KEY, URL_SEGMENT, STRICTNESS, BUILDER_ONLY (its docblock now sits
on it; STRUCTURE stays in AttrWriter). Code of the pipeline moved unchanged.
- ValueCleaner's docblock is the one canonical description: steps 0-8 with their
  rulings in code order, invariants I1-I3; the step comments in cleanNode carry
  the same numbers. AttrWriter/ShortformWriter/Composer/TextReplacer docblocks
  point there and keep only their own rules.
- ValueCleaner::textReplacementFor($module) holds the D10 rule that was a
  closure in DiviReplaceTextAbility::replace (text/html only, clean() by kind).
- AttrWriter, ShortformWriter and Composer take the ValueCleaner; DiviComposition
  builds one per composition (cleaner()).
Tests: the eight AttrWriterTest methods that only exercised cleanValue/kindOf
moved verbatim to ValueCleanerTest (clean/kindOf, ComposeFixture::cleaner());
mixed tests call the cleaner in place; +1 test for textReplacementFor. Every
provider row and per-test assertion count unchanged.

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

380 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\AttrWriter;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
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
{
$cleaner = ComposeFixture::cleaner();
self::assertSame('', $cleaner->clean('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
self::assertSame('<p>x</p>', $cleaner->clean('divi/text', 'content.innerContent', '<p>x</p><script>y</script>'));
self::assertSame('<script>ok()</script>', $cleaner->clean('divi/code', 'content.innerContent', '<script>ok()</script>'));
self::assertSame('10px', $cleaner->clean('divi/text', 'module.decoration.spacing', '<b>10px</b>'));
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
$cleaner->clean('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
);
self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x']));
}
/**
* Ruling S6: each malicious reference fails and nothing is stored (as an editor without
* unfiltered_html).
*
* @return array<string, array{string, string, array<string, mixed>, array<string, mixed>}>
*/
public static function malicious(): array
{
$cases = [];
foreach (DiviFixtures::maliciousRefs() 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,
];
// S28: markers a real sanitizer rebuilds after the raw check (verified in DDEV).
$reference = '({"type":"content","value":{"name":"post_title","settings":{"before":"<script>alert(1)</script>","after":""}}})$';
$cases['tag-split marker in rich text'] = [
'divi/text', 'content.innerContent', ['desktop' => '$variable<x>' . $reference], self::TEXT,
];
$cases['octet-split marker in a text title'] = [
'divi/heading', 'title.innerContent', ['desktop' => '$variable%41' . $reference], self::HEADING,
];
$cases['tag-split marker deep in a value'] = [
'divi/text', 'module.decoration.background', ['desktop' => ['color' => '$variable<x>' . $reference]], self::TEXT,
];
$cases['sanitizer-built valid reference'] = [
'divi/text',
'content.innerContent',
['desktop' => '$variable<x>({"type":"color","value":{"name":"gcid-a1b2","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()->apply($module, $attrs, [$path => $map], 'attrs');
self::assertArrayHasKey('attrs.' . $path, $result['errors']);
self::assertSame($attrs, $result['attrs']);
}
/** S28: the split-marker rows carry no marker before sanitizing, but the sanitizer rebuilds one. */
public function testSplitMarkersAreRebuiltBySanitizing(): void
{
$sanitizer = ComposeFixture::sanitizer();
$rows = ['tag-split marker in rich text', 'octet-split marker in a text title',
'tag-split marker deep in a value'];
foreach (array_intersect_key(self::malicious(), array_flip($rows)) as $name => [, , $map]) {
$raw = (string) json_encode($map, JSON_UNESCAPED_SLASHES);
$value = is_array($map['desktop']) ? $map['desktop']['color'] : $map['desktop'];
self::assertStringNotContainsString('$variable(', $value, $name);
self::assertStringContainsString('$variable(', $sanitizer->text($value) . $sanitizer->html($value), $name . ' ' . $raw);
}
}
/** Ruling S21: an editor's script in before/after is cleaned out, the reference re-serialized. */
public function testReferenceSettingsAreCleanedForEditors(): void
{
$sent = '$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"<script>alert(1)</script>Titel: "}}})$';
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['content.innerContent' => ['desktop' => $sent]], 'attrs');
self::assertSame([], $result['errors']);
self::assertSame(
'$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"Titel: "}}})$',
$result['attrs']['content']['innerContent']['desktop']['value']
);
}
/** 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']);
// Deeper paths and the canvas portal's custom CSS are gated too (fix round 1, M-2).
foreach (['module.advanced.html.htmlBefore', 'module.decoration.attributes.desktop', 'css.advanced.customCss'] as $path) {
try {
ComposeFixture::cleaner()->clean('divi/canvas-portal', $path, 'x');
self::fail('Accepted for an editor: ' . $path);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
}
}
self::assertSame('x', ComposeFixture::cleaner(true)->clean('divi/canvas-portal', 'css.advanced.customCss', 'x'));
self::assertSame('x', ComposeFixture::cleaner()->clean('divi/text', 'module.advanced.htmlish', 'x'));
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('nur im Divi-Builder');
ComposeFixture::cleaner()->clean('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']
);
}
/**
* Ruling S32a: "<" followed by VT/FF is a literal "<" for strip_tags / sanitize_text_field;
* stripping the control character only after the sanitizer would build a real tag. Controls
* go on the RAW value, so the sanitizer sees – and removes – the tag.
*/
public function testControlCharactersGoBeforeTheSanitizer(): void
{
$writer = ComposeFixture::attrWriter();
$cleaner = ComposeFixture::cleaner();
$img = '<' . chr(11) . 'img src=x onerror=alert(1)>';
$script = '<' . chr(12) . 'script>alert(1)</script>';
// The fake reproduces the WordPress behaviour the attack relies on.
self::assertStringContainsString('<' . chr(11) . 'img', ComposeFixture::sanitizer()->text($img));
foreach ([$img . 'Titel', $script . 'Titel'] as $value) {
$result = $writer->apply('divi/heading', self::HEADING, ['title.innerContent' => ['desktop' => $value]], 'attrs');
self::assertSame([], $result['errors']);
$stored = $result['attrs']['title']['innerContent']['desktop']['value'];
self::assertStringNotContainsString('<', $stored);
$button = $cleaner->clean('divi/button', 'button.innerContent', $value, 'text');
self::assertStringNotContainsString('<', $button);
}
}
/** Ruling S34: value keys are whole identifiers – "$" must not match before a trailing newline. */
public function testValueKeysRejectATrailingNewline(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
'module.decoration.spacing' => ['desktop' => ['padding' => ["top\n" => '1px']]],
], 'attrs');
self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']);
}
/**
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a
* control-split placeholder is the placeholder (resolved and checked), never literal text
* that slipped past the placeholder check. Also deep inside values.
*/
public function testControlCharactersGoBeforePlaceholderResolution(): void
{
$writer = ComposeFixture::attrWriter();
$reference = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) {
$result = $writer->apply(
'divi/heading',
[],
['title.innerContent' => ['desktop' => '{' . $control . '{post:title}}']],
'attrs'
);
self::assertSame([], $result['errors'], (string) ord($control));
self::assertSame($reference, $result['attrs']['title']['innerContent']['desktop']['value']);
// Embedded: refused like "{{post:title}}" inside text.
$embedded = $writer->apply(
'divi/heading',
[],
['title.innerContent' => ['desktop' => 'Hallo {' . $control . '{post:title}}']],
'attrs'
);
self::assertStringContainsString('allein im Wert', $embedded['errors']['attrs.title.innerContent']);
self::assertSame([], $embedded['attrs']);
$deep = $writer->apply(
'divi/button',
[],
['button.innerContent' => ['desktop' => ['text' => "{{post:ti" . $control . "tle}}"]]],
'attrs'
);
self::assertSame([], $deep['errors']);
self::assertSame($reference, $deep['attrs']['button']['innerContent']['desktop']['value']['text']);
}
}
/** Ruling S41: structure attributes are refused whatever the catalog says (also for admins, also null). */
public function testStructureAttributesAreRefused(): void
{
foreach ([
['divi/section', 'module.advanced.type', ['desktop' => 'specialty']],
['divi/section', 'module.advanced.type', null],
['divi/section', 'module.advanced', ['desktop' => ['type' => 'fullwidth']]],
['divi/column', 'module.advanced.specialtyColumns', ['desktop' => '2']],
] as [$module, $path, $map]) {
$result = ComposeFixture::attrWriter(true)->apply($module, [], [$path => $map], 'attrs');
self::assertStringContainsString('Struktur', $result['errors']['attrs.' . $path] ?? '', $module . ' ' . $path);
self::assertSame([], $result['invalid_paths'], $path);
self::assertSame([], $result['attrs'], $path);
}
$column = ComposeFixture::attrWriter()->apply('divi/column', [], ['module.advanced.specialtyColumns' => null], 'attrs');
self::assertStringNotContainsString('section_type', $column['errors']['attrs.module.advanced.specialtyColumns']);
$section = ComposeFixture::attrWriter()->apply('divi/section', [], ['module.advanced.type' => null], 'attrs');
self::assertStringContainsString('section_type', $section['errors']['attrs.module.advanced.type']);
$heading = ComposeFixture::attrWriter()->apply('divi/heading', [], ['module.meta.adminLabel' => ['desktop' => 'x']], 'attrs');
self::assertSame([], $heading['errors'], 'only section type and specialty columns are structure');
}
}