- Divi-Vollformat: headingLevel nur h1 bis h6 (ValueCleaner, StoredNodeGuard, ShortformWriter); sonst ließ sich für Konten ohne unfiltered_html ein Skript-Element erzeugen. - content-update, divi-update und wp-settings-update sind destructive, damit MCP-Clients nachfragen. - mcp-adapter/execute-ability und get-ability-info nehmen auf dem OAuth-Server nur lit/-Abilities an. - release-publish.sh übergibt den Gitea-Token per Konfigurationsdatei statt als curl-Argument. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
321 lines
15 KiB
PHP
321 lines
15 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use WP_Error;
|
||
|
||
final class DiviUpdateAbilityTest extends DiviWriteAbilityTestCase
|
||
{
|
||
public function testUpdateChangesSeveralNodesAllOrNothing(): void
|
||
{
|
||
$failed = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||
['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']],
|
||
['id' => 's0.r0.c1.m0', 'set' => ['level' => 'h9']],
|
||
['id' => 's0.r0.c1.m9', 'label' => 'x'],
|
||
]]);
|
||
self::assertToolError($failed, 'validation_failed');
|
||
$fields = self::clientError($failed)['fields'];
|
||
self::assertArrayHasKey('changes[1].set.level', $fields);
|
||
self::assertArrayHasKey('changes[2].id', $fields, 'every error of the call in one response (S39)');
|
||
self::assertSame([], $this->saved);
|
||
|
||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||
['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']],
|
||
['id' => 's0.r0.c1.m0', 'attrs' => ['title.decoration.font.font' => ['phone' => null]]],
|
||
]]);
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame(['s0.r0.c1.m2', 's0.r0.c1.m0'], array_column($result['changed'], 'id'));
|
||
self::assertSame(['updated', 'updated'], array_column($result['changed'], 'action'));
|
||
self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']);
|
||
$index = NodeIndex::build((new Parser())->parse($this->saved[0]));
|
||
self::assertSame('Jetzt buchen', $index->find('s0.r0.c1.m2')?->attrs()['button']['innerContent']['desktop']['value']['text']);
|
||
self::assertArrayNotHasKey('phone', $index->find('s0.r0.c1.m0')?->attrs()['title']['decoration']['font']['font']);
|
||
// Untouched section s1 keeps its exact bytes.
|
||
self::assertStringContainsString(substr($this->original, (int) strpos($this->original, '<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Leistungen"'), -1), $this->saved[0]);
|
||
}
|
||
|
||
public function testUpdateWithoutRealChangeSavesNothing(): void
|
||
{
|
||
$result = $this->update()->execute([
|
||
'post_id' => 7,
|
||
'tree_rev' => $this->treeRev(),
|
||
'changes' => [['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Termin buchen']]],
|
||
]);
|
||
|
||
self::assertStringStartsWith('Keine Änderung', $result['summary']);
|
||
self::assertSame($this->treeRev(), $result['tree_rev']);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
public function testUpdateRefusesAStaleTreeRev(): void
|
||
{
|
||
$result = $this->update()->execute([
|
||
'post_id' => 7,
|
||
'tree_rev' => '000000000000',
|
||
'changes' => [['id' => 's0.r0.c1.m2', 'label' => 'x']],
|
||
]);
|
||
|
||
self::assertToolError($result, 'rev_mismatch');
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
public function testReadOnlyNodesCannotBeUpdated(): void
|
||
{
|
||
$this->post->post_content = DiviFixtures::markup('synthetic-mixed.html');
|
||
|
||
$result = $this->update()->execute([
|
||
'post_id' => 7,
|
||
'tree_rev' => Rev::of($this->post->post_content),
|
||
'changes' => [['id' => 's1.g1', 'label' => 'x']],
|
||
]);
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertStringContainsString('nur lesbar', $result->get_error_message());
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Ruling S14: the same node twice in one call would silently lose the first change. */
|
||
public function testUpdateRefusesADuplicateId(): void
|
||
{
|
||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||
['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Eins']],
|
||
['id' => 's0.r0.c1.m2', 'set' => ['url' => '/zwei']],
|
||
]]);
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertStringContainsString('changes[0]', self::clientError($result)['fields']['changes[1].id']);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Ruling S38: set and attrs of one change must not reach the same attribute path. */
|
||
public function testUpdateRefusesSetAndAttrsOnTheSamePath(): void
|
||
{
|
||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
|
||
'id' => 's0.r0.c1.m2',
|
||
'set' => ['text' => 'Neu'],
|
||
'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]],
|
||
]]]);
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertArrayHasKey('changes[0].attrs.button.innerContent', self::clientError($result)['fields']);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Ruling S41: a column width change must still fill its row exactly. */
|
||
public function testUpdateChecksTheRowWidths(): void
|
||
{
|
||
$sizing = static fn (string $width): array => ['module.decoration.sizing' => ['desktop' => ['flexType' => $width]]];
|
||
|
||
$broken = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||
['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')],
|
||
]]);
|
||
self::assertToolError($broken, 'validation_failed');
|
||
self::assertArrayHasKey('changes[0].attrs.module.decoration.sizing', self::clientError($broken)['fields']);
|
||
self::assertSame([], $this->saved);
|
||
|
||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||
['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')],
|
||
['id' => 's0.r0.c1', 'attrs' => $sizing('12_24')],
|
||
]]);
|
||
self::assertTrue($result['ok'], (string) json_encode($result));
|
||
self::assertCount(1, $this->saved);
|
||
self::assertStringContainsString('"flexType":"12_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/image', $this->saved[0]);
|
||
}
|
||
|
||
/** @param array<string, mixed> $attrs */
|
||
private function updateOn(string $content, array $changes): mixed
|
||
{
|
||
$this->post->post_content = $content;
|
||
|
||
return $this->update()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'changes' => $changes]);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private static function sizing(?string $width): array
|
||
{
|
||
return ['module.decoration.sizing' => $width === null ? null : ['desktop' => ['flexType' => $width]]];
|
||
}
|
||
|
||
/** Ruling S43: only builder column classes (or null) are accepted as new desktop widths. */
|
||
public function testUpdateChecksTheColumnClassFormat(): void
|
||
{
|
||
foreach (['4_5', '13_25', '12_24,12_24', 'auto'] as $width) {
|
||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||
['id' => 's0.r0.c0', 'attrs' => self::sizing($width)],
|
||
]]);
|
||
self::assertInstanceOf(WP_Error::class, $result, $width);
|
||
self::assertStringContainsString('none', self::clientError($result)['fields']['changes[0].attrs.module.decoration.sizing'], $width);
|
||
}
|
||
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Ruling S49: tablet/phone (every non-desktop breakpoint) flexTypes are format-checked like desktop. */
|
||
public function testUpdateChecksTheColumnClassFormatOnOtherBreakpoints(): void
|
||
{
|
||
foreach (['tablet', 'phone', 'phoneWide'] as $breakpoint) {
|
||
foreach (['4_5', '13_25', 'auto'] as $width) {
|
||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
|
||
'id' => 's0.r0.c0',
|
||
'attrs' => ['module.decoration.sizing' => [
|
||
'desktop' => ['flexType' => '8_24'],
|
||
$breakpoint => ['flexType' => $width],
|
||
]],
|
||
]]]);
|
||
self::assertInstanceOf(WP_Error::class, $result, $breakpoint . ' ' . $width);
|
||
$message = self::clientError($result)['fields']['changes[0].attrs.module.decoration.sizing'];
|
||
self::assertStringContainsString($breakpoint, $message);
|
||
self::assertStringContainsString('none', $message);
|
||
}
|
||
}
|
||
self::assertSame([], $this->saved);
|
||
|
||
$valid = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
|
||
'id' => 's0.r0.c0',
|
||
'attrs' => ['module.decoration.sizing' => [
|
||
'desktop' => ['flexType' => '8_24'],
|
||
'tablet' => ['flexType' => '24_24'],
|
||
'phone' => ['flexType' => 'none'],
|
||
]],
|
||
]]]);
|
||
self::assertTrue($valid['ok'], (string) json_encode($valid));
|
||
self::assertStringContainsString('"tablet":{"value":{"flexType":"24_24"}},"phone":{"value":{"flexType":"none"}}', $this->saved[0]);
|
||
}
|
||
|
||
/** Ruling S43: rows that are no plain exact row are not sum-checked (none, omitted width, wrap). */
|
||
public function testUpdateAcceptsWidthChangesInLooseRows(): void
|
||
{
|
||
$noneRow = str_replace('"flexType":"8_24"', '"flexType":"none"', $this->original);
|
||
self::assertTrue($this->updateOn($noneRow, [['id' => 's0.r0.c1', 'attrs' => self::sizing('12_24')]])['ok']);
|
||
|
||
$omitted = str_replace(
|
||
'{"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"8_24"}}}}},"builderVersion":"5.2.1"}',
|
||
'{"builderVersion":"5.2.1"}',
|
||
$this->original
|
||
);
|
||
self::assertNotSame($omitted, $this->original);
|
||
self::assertTrue($this->updateOn($omitted, [['id' => 's0.r0.c1', 'attrs' => self::sizing('12_24')]])['ok']);
|
||
self::assertTrue($this->updateOn($omitted, [['id' => 's0.r0.c1', 'attrs' => self::sizing(null)]])['ok'], 'removal');
|
||
|
||
$wrap = str_replace(
|
||
'<!-- wp:divi/row {"module":{"meta":',
|
||
'<!-- wp:divi/row {"module":{"decoration":{"layout":{"desktop":{"value":{"flexWrap":"wrap"}}}},"meta":',
|
||
$this->original
|
||
);
|
||
self::assertNotSame($wrap, $this->original);
|
||
self::assertTrue($this->updateOn($wrap, [['id' => 's0.r0.c0', 'attrs' => self::sizing('12_24')]])['ok']);
|
||
|
||
self::assertTrue($this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing('none')]])['ok'], 'none itself');
|
||
self::assertCount(5, $this->saved);
|
||
}
|
||
|
||
/** Ruling S43: a plain exact row stays strict; removal counts as Divi's default 24_24. */
|
||
public function testUpdateKeepsExactRowsStrict(): void
|
||
{
|
||
$broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing(null)]]);
|
||
self::assertToolError($broken, 'validation_failed');
|
||
$broken = $this->updateOn($this->original, [['id' => 's0.r0.c0', 'attrs' => self::sizing('10_24')]]);
|
||
self::assertToolError($broken, 'validation_failed');
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Ruling S7: builder-only paths are refused for users without unfiltered_html – visible to the client. */
|
||
public function testBuilderOnlyPathsAreRefusedForEditors(): void
|
||
{
|
||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[
|
||
'id' => 's0.r0.c1.m1',
|
||
'attrs' => ['module.decoration.attributes' => ['desktop' => ['attributes' => [['name' => 'onclick', 'value' => 'x']]]]],
|
||
]]]);
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
$client = self::clientError($result);
|
||
self::assertSame('validation_failed', $client['code']);
|
||
self::assertStringContainsString('nur im Divi-Builder', $client['fields']['changes[0].attrs.module.decoration.attributes']);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Ruling P13: the outline of a write result names library IDs only with library access. */
|
||
public function testTheWriteOutlineNamesLibraryIdsOnlyWithLibraryAccess(): void
|
||
{
|
||
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
|
||
$changes = [['id' => 's0.r0.c0.m0', 'set' => ['text' => 'Termin buchen']]];
|
||
|
||
$hidden = $this->updateOn($content, $changes);
|
||
$failures = [];
|
||
$this->writer = DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader());
|
||
$shown = $this->updateOn($content, $changes);
|
||
|
||
self::assertTrue($hidden['ok']);
|
||
self::assertStringContainsString('s0.r0.c0.g0 text', (string) $hidden['outline']);
|
||
self::assertStringContainsString('(nur lesbar)', (string) $hidden['outline']);
|
||
self::assertStringNotContainsString('1059', (string) json_encode($hidden));
|
||
self::assertStringNotContainsString('1058', (string) json_encode($hidden));
|
||
self::assertStringContainsString('[global 1059]', (string) $shown['outline']);
|
||
}
|
||
|
||
public function testUpdateOffersPresetStacksGroupPresetsAndClearOverrides(): void
|
||
{
|
||
$change = $this->update()->inputSchema()['properties']['changes']['items']['properties'];
|
||
|
||
self::assertSame(['string', 'array', 'null'], $change['preset']['type']);
|
||
self::assertSame(['type' => 'string'], $change['preset']['items']);
|
||
self::assertSame([1, 10], [$change['preset']['minItems'], $change['preset']['maxItems']]);
|
||
self::assertSame('object', $change['group_presets']['type']);
|
||
self::assertSame(['string', 'array', 'null'], $change['group_presets']['additionalProperties']['type']);
|
||
self::assertSame([1, 10], [
|
||
$change['group_presets']['additionalProperties']['minItems'],
|
||
$change['group_presets']['additionalProperties']['maxItems'],
|
||
]);
|
||
self::assertSame('boolean', $change['clear_overrides']['type']);
|
||
|
||
$description = $this->update()->description();
|
||
foreach (['group_presets', 'clear_overrides', 'das letzte Preset'] as $text) {
|
||
self::assertStringContainsString($text, $description);
|
||
}
|
||
self::assertStringContainsString('group_presets', $this->insert()->description());
|
||
}
|
||
|
||
public function testClearOverridesMustBeABoolean(): void
|
||
{
|
||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||
['id' => 's0.r0.c1.m2', 'clear_overrides' => 'ja'],
|
||
]]);
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertSame(
|
||
'true oder false erwartet',
|
||
self::clientError($result)['fields']['changes[0].clear_overrides']
|
||
);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** A pure clear_overrides that finds no covering value changes nothing and saves nothing. */
|
||
public function testClearOverridesWithoutAHitIsNoChange(): void
|
||
{
|
||
$result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [
|
||
['id' => 's0.r0.c1.m2', 'clear_overrides' => true],
|
||
]]);
|
||
|
||
self::assertStringStartsWith('Keine Änderung', $result['summary']);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** Security audit 2026-11, B2: changing attributes of existing modules overwrites them. */
|
||
public function testOverwritesSoItIsMarkedDestructive(): void
|
||
{
|
||
$annotations = $this->update()->annotations();
|
||
|
||
self::assertTrue($annotations['destructive']);
|
||
self::assertFalse($annotations['readonly']);
|
||
}
|
||
}
|