Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php
T
janniclas.loosenandClaude Opus 5.5 7762b76305 feat: Divi save pipeline – tree_rev lock, kses-safe save, revision, flags, cache, ToolResult
DiviSaver/DiviWriter/DiviComposition/DiviChange, WpValueSanitizer (S4),
WpMediaResolver, DiviFlags (S11), DynamicContentMap from Divi's live
registry (S22), S31 wiring, S35 control strip at apply() entry.

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

355 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 PHPUnit\Framework\TestCase;
final class ShortformWriterTest extends TestCase
{
/** Existing attributes: an error must leave them exactly as they were (nothing stored). */
private const BASE = [
'title' => ['innerContent' => ['desktop' => ['value' => 'Alt']]],
'builderVersion' => '5.2.1',
];
/** Short-form field per value kind the malicious suite attacks: [type, field]. */
private const TARGETS = [
'text' => ['heading', 'text'],
'url' => ['button', 'url'],
'color' => ['divider', 'color'],
];
public function testWritesFieldsAtTheirPaths(): void
{
$result = ComposeFixture::shortform()->apply('button', [], [
'text' => 'Termin <b>buchen</b>',
'url' => '/kontakt',
'new_window' => true,
], 'nodes[0]');
self::assertSame([], $result['errors']);
self::assertSame(
['button' => ['innerContent' => ['desktop' => ['value' => [
'text' => 'Termin buchen', 'linkUrl' => '/kontakt', 'linkTarget' => 'on',
]]]]],
$result['attrs']
);
}
public function testMediaSetsUrlIdAndAltUnlessAltIsGiven(): void
{
$writer = ComposeFixture::shortform();
$image = $writer->apply('image', [], ['image_id' => 33], 'n');
self::assertSame(
['src' => 'https://kunde.test/wp-content/uploads/team.jpg', 'id' => '33', 'alt' => 'Unser Team'],
$image['attrs']['image']['innerContent']['desktop']['value']
);
$own = $writer->apply('image', [], ['image_id' => 33, 'alt' => 'Team 2026'], 'n');
self::assertSame('Team 2026', $own['attrs']['image']['innerContent']['desktop']['value']['alt']);
$blurb = $writer->apply('blurb', [], ['image_id' => 12, 'title' => 'Halle'], 'n');
self::assertSame(
['src' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'useIcon' => 'off'],
$blurb['attrs']['imageIcon']['innerContent']['desktop']['value']
);
$gallery = $writer->apply('gallery', [], ['image_ids' => [12, 33]], 'n');
self::assertSame('12,33', $gallery['attrs']['image']['advanced']['galleryIds']['desktop']['value']);
}
public function testValidatesKindsAndNames(): void
{
$result = ComposeFixture::shortform()->apply('heading', [], [
'level' => 'h7',
'text' => '{{color:Lila}}',
'size' => 12,
], 'nodes[1]');
self::assertSame('h1 bis h6 erwartet', $result['errors']['nodes[1].level']);
self::assertStringContainsString('Unbekannte globale Farbe', $result['errors']['nodes[1].text']);
self::assertSame('unbekanntes Feld; erlaubt: text, level', $result['errors']['nodes[1].size']);
$more = ComposeFixture::shortform()->apply('contact-field', [], ['type' => 'date', 'required' => 'ja'], 'f');
self::assertStringContainsString('erlaubt: input, email', $more['errors']['f.type']);
self::assertSame('true oder false erwartet', $more['errors']['f.required']);
$media = ComposeFixture::shortform()->apply('image', [], ['image_id' => 999, 'url' => 'javascript:x'], 'm');
self::assertStringContainsString('kein Bild aus der Mediathek', $media['errors']['m.image_id']);
self::assertSame('ungültige URL', $media['errors']['m.url']);
$none = ComposeFixture::shortform()->apply('accordion', [], ['title' => 'x'], 'a');
self::assertStringContainsString('keine Kurzform', $none['errors']['a.title']);
$numbers = ComposeFixture::shortform()->apply('map', [], ['zoom' => '12abc', 'lng' => '1e999', 'lat' => '50.1'], 'm');
self::assertSame('Zahl erwartet', $numbers['errors']['m.zoom']);
self::assertSame('Zahl erwartet', $numbers['errors']['m.lng']);
self::assertSame(50.1, $numbers['attrs']['map']['innerContent']['desktop']['value']['lat']);
self::assertArrayNotHasKey('m.lat', $numbers['errors']);
$ids = ComposeFixture::shortform()->apply('gallery', [], ['image_ids' => [12, 999]], 'g');
self::assertStringContainsString('999 ist kein Bild', $ids['errors']['g.image_ids']);
self::assertSame([], $ids['attrs']);
}
public function testPlaceholdersAndNullRemoval(): void
{
$writer = ComposeFixture::shortform();
$attrs = $writer->apply('heading', [], ['text' => '{{post:title}}', 'level' => 'h2'], 'n')['attrs'];
self::assertSame(
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
$attrs['title']['innerContent']['desktop']['value']
);
$removed = $writer->apply('heading', $attrs, ['level' => null], 'n')['attrs'];
self::assertArrayNotHasKey('decoration', $removed['title'], 'empty parents are pruned');
$divider = $writer->apply('divider', [], ['color' => '{{color:Nachtblau}}'], 'n')['attrs'];
self::assertStringContainsString('gcid-a1b2', $divider['divider']['advanced']['line']['desktop']['value']['color']);
self::assertSame(
'Farbe erwartet, z. B. {{color:primary}} oder #1a1a1a',
$writer->apply('divider', [], ['color' => 'blau;}'], 'n')['errors']['n.color']
);
}
/** The curated kind wins over catalog routing: icon-list-item text is plain text, not rich text (S23). */
public function testCuratedKindDecidesTheSanitizer(): void
{
$writer = ComposeFixture::shortform();
$item = $writer->apply('icon-list-item', [], ['text' => 'Schnell <b>geliefert</b>'], 'n')['attrs'];
self::assertSame('Schnell geliefert', $item['content']['innerContent']['desktop']['value']);
$text = $writer->apply('text', [], ['content' => '<p>Hallo <b>Welt</b></p><script>x()</script>'], 'n')['attrs'];
self::assertSame('<p>Hallo <b>Welt</b></p>', $text['content']['innerContent']['desktop']['value']);
$link = $writer->apply('image', [], ['src' => '{{post:featured_image}}'], 'n');
self::assertSame([], $link['errors']);
self::assertStringContainsString('post_featured_image', $link['attrs']['image']['innerContent']['desktop']['value']['src']);
}
/**
* Ruling S6 (Task 2 part): attacks on short-form text, url and color. Every Task-1 attack on a
* $variable(…)$ value runs against each of the three kinds, plus kind-specific rows.
*
* The Task-1 rows run for text as an editor only (as in AttrWriterTest): for a user with
* unfiltered_html, html() keeps before/after raw, so a split marker there stays split and
* harmless (no marker in the final string). Every other row fails for both roles.
*
* @return array<string, array{0: string, 1: mixed, 2?: bool}> Kind, value, also as admin (default true).
*/
public static function malicious(): array
{
$rows = [];
foreach (VariableRefTest::malicious() as $name => [$value]) {
foreach (array_keys(self::TARGETS) as $kind) {
$rows[$kind . ': ' . $name] = [$kind, $value, $kind !== 'text'];
}
}
$postTitle = static fn (string $settings): string
=> '$variable({"type":"content","value":{"name":"post_title","settings":' . $settings . '}})$';
$meta = static fn (string $settings): string
=> '$variable({"type":"content","value":{"name":"custom_meta_ziel","settings":' . $settings . '}})$';
$color = '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$';
return $rows + [
// Script in before/after through a placeholder.
'text: script after a placeholder' => ['text', '{{post:title}}<script>alert(1)</script>'],
'text: script in a placeholder name' => ['text', '{{post:<script>alert(1)</script>}}'],
'url: script after a placeholder' => ['url', '{{post:url}}<script>alert(1)</script>'],
'color: script after a placeholder' => ['color', '{{color:primary}};}</style><script>alert(1)</script>'],
'url: script in before of a reference' => ['url', $postTitle('{"before":"<script>alert(1)</script>","after":""}')],
'color: script in before of a reference' => ['color', $postTitle('{"before":"<script>alert(1)</script>","after":""}')],
// javascript: links.
'url: javascript' => ['url', 'javascript:alert(1)'],
'url: javascript mixed case' => ['url', 'JavaScript:alert(document.cookie)'],
'url: javascript after spaces' => ['url', ' javascript:alert(1)'],
'url: javascript in before of a reference' => ['url', $meta('{"before":"javascript:","after":""}')],
// CSS breakout in colors.
'color: style breakout' => ['color', '#fff;}</style><script>alert(1)</script>'],
'color: name breakout' => ['color', 'red;}</style>'],
'color: rgba breakout' => ['color', 'rgba(0,0,0,.5);}body{display:none}'],
'color: expression' => ['color', 'expression(alert(1))'],
'color: url()' => ['color', 'url(javascript:alert(1))'],
'color: content reference' => ['color', '{{post:title}}'],
'color: not a string' => ['color', ['#fff']],
// Embedded $variable(.
'text: embedded reference' => ['text', 'Hallo ' . $postTitle('{}')],
'url: embedded reference' => ['url', 'https://kunde.test/?a=' . $postTitle('{}')],
'color: embedded reference' => ['color', '#fff ' . $color],
'color: two references' => ['color', $color . $color],
// Markers the sanitizer would rebuild (S28).
'text: tag-split marker' => ['text', '$variable<x>(' . substr($color, 10)],
'text: NUL-split marker embedded' => ['text', 'Hallo $variable' . chr(0) . '(' . substr($color, 10)],
'text: octet-split marker' => ['text', '$variable%41(' . substr($color, 10)],
'url: stripped-characters marker' => ['url', '$variable<"{}>(y)$'],
'url: NUL-split marker' => ['url', '$variable' . chr(0) . '(y)$'],
'url: line-break octet marker' => ['url', '$variable%0a(y)$'],
'url: nested line-break octet marker' => ['url', '$variable%0%0aa(y)$'],
'color: tag-split marker' => ['color', '$variable<x>(' . substr($color, 10)],
'color: octet-split marker' => ['color', '$variable%41(' . substr($color, 10)],
];
}
/** @dataProvider malicious */
public function testMaliciousValuesAreRejectedAndNothingIsStored(string $kind, mixed $value, bool $admin = true): void
{
[$type, $field] = self::TARGETS[$kind];
foreach ($admin ? [false, true] : [false] as $unfiltered) {
$result = ComposeFixture::shortform($unfiltered)->apply($type, self::BASE, [$field => $value], 'nodes[0]');
self::assertArrayHasKey('nodes[0].' . $field, $result['errors']);
self::assertSame(self::BASE, $result['attrs']);
}
}
/** The split-marker rows really carry no marker, and the fake sanitizers really rebuild one. */
public function testSplitMarkerRowsNeedTheFinalStringCheck(): void
{
$rows = self::malicious();
$sanitizer = ComposeFixture::sanitizer();
foreach (['text: tag-split marker', 'text: octet-split marker'] as $row) {
self::assertStringNotContainsString('$variable(', $rows[$row][1]);
self::assertStringContainsString('$variable(', $sanitizer->text($rows[$row][1]));
}
// S32/S32a: like sanitize_text_field, the fake text() keeps NUL – the marker the NUL row
// hides appears only through AttrWriter's control stripping on the raw value (production code).
self::assertStringNotContainsString('$variable(', $sanitizer->text($rows['text: NUL-split marker embedded'][1]));
self::assertStringContainsString(chr(0), $sanitizer->text($rows['text: NUL-split marker embedded'][1]));
foreach (['url: stripped-characters marker', 'url: NUL-split marker', 'url: line-break octet marker'] as $row) {
self::assertStringNotContainsString('$variable(', $rows[$row][1]);
self::assertStringContainsString('$variable(', $sanitizer->url($rows[$row][1]));
}
self::assertStringContainsString(chr(0), $rows['text: NUL-split marker embedded'][1]);
}
/** Ruling S33b: an attachment alt carrying a reference (whole or split) is dropped, never canonicalized. */
public function testMediaAltWithAReferenceIsDropped(): void
{
foreach ([44, 45] as $id) {
$result = ComposeFixture::shortform()->apply('image', [], ['image_id' => $id], 'n');
self::assertSame([], $result['errors'], (string) $id);
self::assertSame(['src', 'id'], array_keys($result['attrs']['image']['innerContent']['desktop']['value']));
self::assertStringNotContainsString('variable', (string) json_encode($result['attrs']));
}
}
/** Ruling S32a: a control character after "<" never turns into a tag – in text and in media alt. */
public function testControlSplitTagsAreRemoved(): void
{
$writer = ComposeFixture::shortform();
foreach (['<' . chr(11) . 'img src=x onerror=alert(1)>Hallo', '<' . chr(12) . 'script>alert(1)</script>Hallo'] as $value) {
$result = $writer->apply('heading', [], ['text' => $value], 'n');
self::assertSame([], $result['errors']);
self::assertSame('Hallo', $result['attrs']['title']['innerContent']['desktop']['value']);
}
$image = $writer->apply('image', [], ['image_id' => 46], 'n');
self::assertSame([], $image['errors']);
self::assertSame('Team', $image['attrs']['image']['innerContent']['desktop']['value']['alt']);
}
/** Ruling S21 applies to short-form text too: before/after of a reference are cleaned, not rejected. */
public function testReferenceSettingsInTextAreCleaned(): void
{
$value = '$variable({"type":"content","value":{"name":"post_title","settings":'
. '{"before":"<b>Hi</b><script>alert(1)</script>","after":""}}})$';
$result = ComposeFixture::shortform()->apply('heading', [], ['text' => $value], 'n');
self::assertSame([], $result['errors']);
self::assertSame(
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"<b>Hi</b>","after":""}}})$',
$result['attrs']['title']['innerContent']['desktop']['value']
);
}
/** Ruling S13: the color check Task 3 reuses for background_color. */
public function testColorCheckIsReusable(): void
{
$writer = ComposeFixture::shortform();
self::assertSame('#1A1a1a', $writer->color('divi/section', 'module.decoration.background', ' #1A1a1a ', 'color'));
self::assertSame('rgba(0, 0, 0, 0.5)', $writer->color('divi/section', 'module.decoration.background', 'rgba(0, 0, 0, 0.5)', 'color'));
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$',
$writer->color('divi/section', 'module.decoration.background', '{{color:primary}}', 'color')
);
foreach (['{{post:title}}', 'blau', '#fff;}', '', "#fff\n}"] as $bad) {
try {
$writer->color('divi/section', 'module.decoration.background', $bad, 'color');
self::fail('accepted ' . $bad);
} catch (InvalidArgumentException $e) {
self::assertNotSame('', $e->getMessage());
}
}
}
/** Rulings S7/S26: the short form never reaches builder-only paths without unfiltered_html. */
public function testBuilderOnlyPathsNeedUnfilteredHtml(): void
{
$modules = ['raw' => [
'before' => ['path' => 'module.advanced.html', 'sub' => 'htmlBefore', 'kind' => 'text', 'description' => 'x'],
'css' => ['path' => 'css.advanced.customCss', 'sub' => null, 'kind' => 'text', 'description' => 'x'],
]];
$editor = ComposeFixture::shortform(false, $modules);
foreach (['before', 'css'] as $field) {
foreach (['Hallo', null] as $value) {
$result = $editor->apply('raw', self::BASE, [$field => $value], 'n');
self::assertStringContainsString('nur im Divi-Builder', $result['errors']['n.' . $field]);
self::assertSame(self::BASE, $result['attrs']);
}
}
$admin = ComposeFixture::shortform(true, $modules)->apply('raw', [], ['before' => '<b>Hallo</b>'], 'n');
self::assertSame([], $admin['errors']);
self::assertSame('Hallo', $admin['attrs']['module']['advanced']['html']['desktop']['value']['htmlBefore']);
}
/**
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution and
* trim – "{\0{post:title}}" is the placeholder, checked like any other.
*/
public function testControlCharactersGoBeforePlaceholderResolution(): void
{
$writer = ComposeFixture::shortform();
foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) {
$result = $writer->apply('heading', [], ['text' => '{' . $control . '{post:title}}'], 'n');
self::assertSame([], $result['errors'], (string) ord($control));
self::assertSame(
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
$result['attrs']['title']['innerContent']['desktop']['value']
);
$embedded = $writer->apply('heading', [], ['text' => 'Hallo {' . $control . '{post:title}}'], 'n');
self::assertStringContainsString('allein im Wert', $embedded['errors']['n.text']);
self::assertSame([], $embedded['attrs']);
// A control-split whole reference IS the reference (S32a/S35): validated and re-serialized.
$reference = '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$';
$split = $writer->apply('divider', [], ['color' => '$variable' . $control . substr($reference, 9)], 'n');
self::assertSame([], $split['errors']);
self::assertSame($reference, $split['attrs']['divider']['advanced']['line']['desktop']['value']['color']);
// Colors: the placeholder resolves after the strip (and trim), then the color check.
$color = $writer->apply('divider', [], ['color' => ' {{color:Nacht' . $control . 'blau}} '], 'n');
self::assertSame([], $color['errors']);
self::assertStringContainsString('gcid-a1b2', $color['attrs']['divider']['advanced']['line']['desktop']['value']['color']);
}
}
}