Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.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

307 lines
16 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' => ['text', '$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: NUL-split marker' => ['color', '$variable' . chr(0) . '(' . 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: like sanitize_text_field, the fake text() keeps NUL – so the marker the NUL row
// hides is rebuilt only by AttrWriter's control-character stripping (production code).
self::assertStringNotContainsString('$variable(', $sanitizer->text($rows['text: NUL-split marker'][1]));
self::assertStringContainsString(chr(0), $sanitizer->text($rows['text: NUL-split marker'][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'][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 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']);
}
}