fix: harden the Divi BlockTree against opaque/invalid writes
Final fix wave for stage 2a, load-bearing for 2b's writers:
- Block::setAttrs()/Container::insertChild()/removeChild() refuse on an opaque
block (undecodable attrs or a global module) instead of silently corrupting it.
- Container::append() is now @internal (Parser only) and refuses on a void
block, so a child can no longer be silently dropped from the serialized output.
- Block::create() validates the block name against namespace/block-name.
- Json::decodeObject() returns null (-> opaque) for a top-level object whose
keys are a plain list ("0","1",...) rather than returning [] and letting the
Serializer re-encode it as a corrupting JSON array.
- AttrMap::set() rejects empty path segments and keeps an existing stdClass
value an object when rewrapping it (no silent object->list flip); AttrMap::get()
is updated in step so it can still read back through such an object.
- Block::copy() deep-clones stdClass attribute values instead of sharing them.
- Json::encode()'s existing JsonException-on-invalid-UTF-8 behaviour is documented.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
|
||||
final class AttrMapTest extends TestCase
|
||||
{
|
||||
/** @return array<string, array{string}> */
|
||||
public static function emptySegmentPaths(): array
|
||||
{
|
||||
return [
|
||||
'double dot' => ['a..b'],
|
||||
'leading dot' => ['.a'],
|
||||
'trailing dot' => ['a.'],
|
||||
'empty' => [''],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider emptySegmentPaths */
|
||||
public function testSetRejectsEmptyPathSegments(string $path): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
AttrMap::set([], $path, 'z');
|
||||
}
|
||||
|
||||
public function testSetKeepsAnObjectAnObjectWhenRewrapping(): void
|
||||
{
|
||||
$attrs = ['a' => new stdClass()];
|
||||
|
||||
$changed = AttrMap::set($attrs, 'a.0', 'z');
|
||||
|
||||
self::assertInstanceOf(stdClass::class, $changed['a']);
|
||||
self::assertSame('z', $changed['a']->{'0'});
|
||||
self::assertSame('{"a":{"0":"z"}}', json_encode($changed));
|
||||
}
|
||||
|
||||
public function testSetStillBuildsPlainArraysWhenNoObjectExisted(): void
|
||||
{
|
||||
$changed = AttrMap::set([], 'a.b', 'z');
|
||||
|
||||
self::assertSame(['a' => ['b' => 'z']], $changed);
|
||||
}
|
||||
|
||||
/** get() must still read back through a value set() kept as an object (regression guard). */
|
||||
public function testGetReadsThroughAnObjectRewrappedBySet(): void
|
||||
{
|
||||
$attrs = ['a' => new stdClass()];
|
||||
$changed = AttrMap::set($attrs, 'a.b', 'z');
|
||||
|
||||
self::assertSame('z', AttrMap::get($changed, 'a.b'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LogicException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
|
||||
final class BlockTest extends TestCase
|
||||
{
|
||||
/** @return array<string, array{string}> */
|
||||
public static function invalidNames(): array
|
||||
{
|
||||
return [
|
||||
'no namespace' => ['button'],
|
||||
'trailing markup injection' => ['divi/x --><script>'],
|
||||
'uppercase' => ['Divi/Button'],
|
||||
'empty' => [''],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider invalidNames */
|
||||
public function testCreateRejectsInvalidBlockNames(string $name): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
Block::create($name);
|
||||
}
|
||||
|
||||
public function testCreateAcceptsAWellFormedName(): void
|
||||
{
|
||||
self::assertSame('divi/button', Block::create('divi/button')->name());
|
||||
}
|
||||
|
||||
public function testSetAttrsRefusesOnAnOpaqueGlobalModule(): void
|
||||
{
|
||||
$document = (new Parser())->parse('<!-- wp:divi/section {"globalModule":"x"} --><!-- /wp:divi/section -->');
|
||||
$section = $document->children()[0];
|
||||
self::assertTrue($section->isOpaque());
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$section->setAttrs(['a' => 1]);
|
||||
}
|
||||
|
||||
public function testSetAttrsRefusesOnABlockWithUndecodableAttrs(): void
|
||||
{
|
||||
$document = (new Parser())->parse('<!-- wp:divi/text {"a":} /-->');
|
||||
$block = $document->children()[0];
|
||||
self::assertTrue($block->isOpaque());
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$block->setAttrs(['a' => 1]);
|
||||
}
|
||||
|
||||
public function testCopyDeepClonesStdClassAttributeValues(): void
|
||||
{
|
||||
$block = Block::create('divi/text', ['localAttrs' => new stdClass(), 'nested' => ['obj' => new stdClass()]]);
|
||||
$copy = $block->copy();
|
||||
|
||||
self::assertNotSame($block->attrs()['localAttrs'], $copy->attrs()['localAttrs']);
|
||||
self::assertEquals($block->attrs()['localAttrs'], $copy->attrs()['localAttrs']);
|
||||
self::assertNotSame($block->attrs()['nested']['obj'], $copy->attrs()['nested']['obj']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LogicException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ContainerTest extends TestCase
|
||||
{
|
||||
public function testInsertChildRefusesOnAnOpaqueBlock(): void
|
||||
{
|
||||
$document = (new Parser())->parse('<!-- wp:divi/section {"globalModule":"x"} --><!-- /wp:divi/section -->');
|
||||
$section = $document->children()[0];
|
||||
self::assertTrue($section->isOpaque());
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$section->insertChild(0, Block::create('divi/text'));
|
||||
}
|
||||
|
||||
public function testInsertChildRefusesOnABlockWithUndecodableAttrs(): void
|
||||
{
|
||||
$document = (new Parser())->parse('<!-- wp:divi/section {"a":} --><!-- /wp:divi/section -->');
|
||||
$section = $document->children()[0];
|
||||
self::assertTrue($section->isOpaque());
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$section->insertChild(0, Block::create('divi/text'));
|
||||
}
|
||||
|
||||
public function testRemoveChildRefusesOnAnOpaqueBlock(): void
|
||||
{
|
||||
$document = (new Parser())->parse(
|
||||
'<!-- wp:divi/section {"globalModule":"x"} --><!-- wp:divi/row --><!-- /wp:divi/row --><!-- /wp:divi/section -->'
|
||||
);
|
||||
$section = $document->children()[0];
|
||||
$row = $section->children()[0];
|
||||
self::assertTrue($section->isOpaque());
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$section->removeChild($row);
|
||||
}
|
||||
|
||||
public function testAppendRefusesOnAVoidBlock(): void
|
||||
{
|
||||
$void = Block::parsed('divi/image', [], '<!-- wp:divi/image /-->', true, false);
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$void->append('text');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||||
|
||||
use JsonException;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Json;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
|
||||
final class JsonTest extends TestCase
|
||||
{
|
||||
public function testDecodesAPlainObject(): void
|
||||
{
|
||||
self::assertSame(['a' => 1], Json::decodeObject('{"a":1}'));
|
||||
self::assertSame([], Json::decodeObject('{}'));
|
||||
}
|
||||
|
||||
public function testRejectsNonObjectTopLevelJson(): void
|
||||
{
|
||||
self::assertNull(Json::decodeObject('[1,2,3]'));
|
||||
self::assertNull(Json::decodeObject('"a string"'));
|
||||
self::assertNull(Json::decodeObject('not json'));
|
||||
}
|
||||
|
||||
/** A JSON object whose keys are "0", "1", … decodes to a PHP list – re-encoding it would
|
||||
* silently turn it back into a JSON array (Json.php docblock, EmptyArrayCorruptionMigration). */
|
||||
public function testTreatsAListLikeTopLevelObjectAsOpaqueRatherThanAnArray(): void
|
||||
{
|
||||
self::assertNull(Json::decodeObject('{"0":"a","1":"b"}'));
|
||||
self::assertNull(Json::decodeObject('{"0":"a"}'));
|
||||
}
|
||||
|
||||
public function testDoesNotFlagAGenuineObjectWithNonSequentialNumericKeys(): void
|
||||
{
|
||||
self::assertSame(['0' => 'a', '2' => 'b'], Json::decodeObject('{"0":"a","2":"b"}'));
|
||||
}
|
||||
|
||||
public function testEncodeThrowsOnInvalidUtf8(): void
|
||||
{
|
||||
$this->expectException(JsonException::class);
|
||||
|
||||
Json::encode("\xB1\x31");
|
||||
}
|
||||
|
||||
public function testEncodeRoundtripsAnEmptyObject(): void
|
||||
{
|
||||
self::assertSame('{}', Json::encode(new stdClass()));
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,16 @@ final class ParserTest extends TestCase
|
||||
self::assertSame([], $broken->children()[0]->attrs());
|
||||
}
|
||||
|
||||
public function testMarksListLikeTopLevelAttributesOpaque(): void
|
||||
{
|
||||
$content = '<!-- wp:divi/text {"0":"a","1":"b"} /-->';
|
||||
$document = (new Parser())->parse($content);
|
||||
|
||||
self::assertTrue($document->children()[0]->isOpaque());
|
||||
self::assertSame([], $document->children()[0]->attrs());
|
||||
self::assertSame($content, Serializer::document($document));
|
||||
}
|
||||
|
||||
public function testKeepsEmptyAndListLikeObjectsAsObjects(): void
|
||||
{
|
||||
$document = (new Parser())->parse($this->fixture('synthetic-mixed.html'));
|
||||
|
||||
Reference in New Issue
Block a user