fix: the Lottie source is builder-only for users without unfiltered_html (P19)

lottie.innerContent (src → data-lottie-src) makes the frontend load an
animation whose expressions lottie-web runs with eval; it joins the S7 gate
in ValueCleaner, which covers removals and StoredNodeGuard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 02:06:38 +02:00
co-authored by Claude Opus 5.5
parent 16c6d59725
commit 8fa285a265
6 changed files with 90 additions and 9 deletions
@@ -6,6 +6,8 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
@@ -405,4 +407,52 @@ final class AttrWriterTest extends TestCase
self::assertSame([], $adminRemoval['errors']);
self::assertArrayNotHasKey('interactions', $adminRemoval['attrs']['module']['decoration'] ?? []);
}
/**
* The Lottie module of Divi 5.2.1 (excerpt of lottie/module.json: the animation source group) –
* the sample metadata has no Lottie module.
*/
private static function lottieWriter(bool $unfiltered): AttrWriter
{
$catalog = new ModuleCatalog([[
'name' => 'divi/lottie',
'title' => 'Lottie',
'category' => 'module',
'attributes' => ['lottie' => ['type' => 'object', 'settings' => ['innerContent' => [
'groupType' => 'group-item',
'item' => [
'attrName' => 'lottie.innerContent',
'component' => ['type' => 'field', 'name' => 'divi/lottie'],
],
]]]],
]]);
$cleaner = new ValueCleaner($catalog, ComposeFixture::references(), ComposeFixture::sanitizer($unfiltered));
return new AttrWriter($catalog, ComposeFixture::placeholders(), $cleaner);
}
/** Ruling P19: the Lottie source only with unfiltered_html – set and removal. */
public function testLottieSourceNeedsUnfilteredHtml(): void
{
$changes = ['lottie.innerContent' => ['desktop' => ['src' => 'https://probe.example/anim.json']]];
$remove = ['lottie.innerContent' => null];
$key = 'attrs.lottie.innerContent';
$editor = self::lottieWriter(false)->apply('divi/lottie', [], $changes, 'attrs');
self::assertSame([], $editor['attrs']);
self::assertStringContainsString('nur im Divi-Builder', $editor['errors'][$key]);
$removal = self::lottieWriter(false)->apply('divi/lottie', [], $remove, 'attrs');
self::assertStringContainsString('nur im Divi-Builder', $removal['errors'][$key]);
$admin = self::lottieWriter(true)->apply('divi/lottie', [], $changes, 'attrs');
self::assertSame([], $admin['errors']);
self::assertSame(
'https://probe.example/anim.json',
$admin['attrs']['lottie']['innerContent']['desktop']['value']['src']
);
$adminRemoval = self::lottieWriter(true)->apply('divi/lottie', $admin['attrs'], $remove, 'attrs');
self::assertSame([], $adminRemoval['errors']);
self::assertArrayNotHasKey('lottie', $adminRemoval['attrs']);
}
}
@@ -164,6 +164,13 @@ final class StoredNodeGuardTest extends TestCase
])]]]),
'module.decoration.interactions',
],
'lottie source (P19)' => [
self::node('divi/lottie', ['lottie' => ['innerContent' => self::desktop([
'src' => 'https://probe.example/anim.json',
'trigger' => 'onLoad',
])]]),
'lottie.innerContent',
],
// Code module (E6).
'code module' => [
self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]),
@@ -232,4 +232,25 @@ final class ValueCleanerTest extends TestCase
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'));
}
}