fix: interactions are builder-only for users without unfiltered_html (P16)

Divi's frontend applies the interaction effect "Attribute" with setAttribute
(e.g. an onclick handler) and sets cookies; *.decoration.interactions joins
the S7 gate in ValueCleaner, which also covers removals and StoredNodeGuard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 01:54:25 +02:00
co-authored by Claude Opus 5.5
parent f8275ad325
commit 9d92808918
7 changed files with 107 additions and 7 deletions
@@ -376,4 +376,33 @@ final class AttrWriterTest extends TestCase
$heading = ComposeFixture::attrWriter()->apply('divi/heading', [], ['module.meta.adminLabel' => ['desktop' => 'x']], 'attrs');
self::assertSame([], $heading['errors'], 'only section type and specialty columns are structure');
}
/** Ruling P16: interactions (setAttribute on the frontend) only with unfiltered_html – set and removal. */
public function testInteractionsNeedUnfilteredHtml(): void
{
$interaction = ['interactions' => [[
'trigger' => 'click',
'effect' => 'addAttribute',
'attributeName' => 'data-probe',
'attributeValue' => '1',
]]];
$changes = ['module.decoration.interactions' => ['desktop' => $interaction]];
$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.interactions']);
$removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.decoration.interactions' => null], 'attrs');
self::assertStringContainsString('nur im Divi-Builder', $removal['errors']['attrs.module.decoration.interactions']);
$admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs');
self::assertSame([], $admin['errors']);
self::assertSame(
'data-probe',
$admin['attrs']['module']['decoration']['interactions']['desktop']['value']['interactions'][0]['attributeName']
);
$adminRemoval = ComposeFixture::attrWriter(true)->apply('divi/text', $admin['attrs'], ['module.decoration.interactions' => null], 'attrs');
self::assertSame([], $adminRemoval['errors']);
self::assertArrayNotHasKey('interactions', $adminRemoval['attrs']['module']['decoration'] ?? []);
}
}
@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
@@ -426,4 +427,26 @@ final class ShortformWriterTest extends TestCase
return new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner);
}
/** Ruling P16: no curated short field reaches interactions, and a field that did would be gated. */
public function testInteractionsAreBuilderOnlyInTheShortForm(): void
{
foreach (ShortformMap::types() as $type) {
foreach (ShortformMap::fields($type) as $name => $field) {
self::assertStringNotContainsString('interactions', $field['path'], $type . '.' . $name);
}
}
$modules = ['raw' => [
'effects' => ['path' => 'module.decoration.interactions', 'sub' => 'interactions', 'kind' => 'text', 'description' => 'x'],
]];
foreach (['data-probe', null] as $value) {
$result = ComposeFixture::shortform(false, $modules)->apply('raw', self::BASE, ['effects' => $value], 'n');
self::assertStringContainsString('nur im Divi-Builder', $result['errors']['n.effects']);
self::assertSame(self::BASE, $result['attrs']);
}
$admin = ComposeFixture::shortform(true, $modules)->apply('raw', [], ['effects' => 'x'], 'n');
self::assertSame([], $admin['errors']);
}
}
@@ -148,6 +148,17 @@ final class StoredNodeGuardTest extends TestCase
])]]),
'module.advanced.html',
],
'interactions (P16)' => [
self::node('divi/text', ['module' => ['decoration' => ['interactions' => self::desktop([
'interactions' => [[
'trigger' => 'click',
'effect' => 'addAttribute',
'attributeName' => 'data-probe',
'attributeValue' => '1',
]],
])]]]),
'module.decoration.interactions',
],
// Code module (E6).
'code module' => [
self::node('divi/code', ['content' => ['innerContent' => self::desktop(self::PROBE_SCRIPT)]]),
@@ -198,4 +198,38 @@ final class ValueCleanerTest extends TestCase
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'));
}
}