Files
WP-LIT-MCP/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php
T
janniclas.loosenandClaude Opus 5.5 5f596b8a17 fix(security): content-create divi follows the Divi group switches (audit M2)
The divi input of lit/content-create is refused with unsupported when
lit/divi-insert is disabled or the Divi group is disabled or read-only
(ExposurePolicy::allows as a cross-group gate, asked at execute time).
content-create without divi keeps working. Adds a registrar test that
read-only write tools are never registered, so execute-ability can't
reach them either.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 19:16:35 +02:00

311 lines
13 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\WordPress\Content;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\WordPress\Content\ContentComposer;
use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility;
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\SchemaFixture;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
use RuntimeException;
use WP_Error;
use WP_Post;
final class ContentCreateDiviTest extends TestCase
{
private const MARKUP = '<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->';
private const SECTIONS = [['type' => 'section']];
private const INPUT = ['post_type' => 'page', 'title' => 'Start', 'divi' => self::SECTIONS];
private function schema(): ContentSchema
{
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3);
$schema = SchemaFixture::managed(true);
return $schema;
}
/** @return PostWriter&MockInterface */
private function writer(): PostWriter
{
$writer = Mockery::mock(PostWriter::class);
$writer->allows('prepare')->andReturn(['fields' => [], 'terms' => [], 'warnings' => []]);
$writer->allows('postData')->andReturn(['post_title' => 'Start']);
$writer->allows('deniedMeta')->andReturn([]);
$writer->allows('writeExtras');
$writer->allows('deleteCreatedTerms');
return $writer;
}
/** @return ContentComposer&MockInterface */
private function composer(bool $available = true, bool $writable = true): ContentComposer
{
$composer = Mockery::mock(ContentComposer::class);
$composer->allows('available')->andReturn($available);
$composer->allows('writable')->andReturn($writable);
$composer->allows('check')->with('page', self::SECTIONS)->andReturn(null);
return $composer;
}
private function log(): ChangeLog
{
$log = FakeChangeLog::any(9);
return $log;
}
private function ability(?ContentComposer $composer, ?PostWriter $writer = null): ContentCreateAbility
{
return new ContentCreateAbility($this->schema(), $writer ?? $this->writer(), $this->log(), $composer);
}
private function stubStoredDraft(): void
{
Functions\when('get_post')->justReturn(new WP_Post([
'ID' => 42, 'post_title' => 'Start', 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => self::MARKUP,
]));
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=42&preview=true');
Functions\when('get_edit_post_link')->justReturn('https://kunde.test/wp-admin/post.php?post=42&action=edit');
}
public function testCreatesADiviDraftWithTreeRevOutlineAndWarnings(): void
{
$composer = $this->composer();
$composer->expects('write')->with(42, 'page', self::SECTIONS)
->andReturn(['warnings' => ['s0: leer (section ohne Inhalt).'], 'outline' => 's0 divi/section']);
// D7: the insert carries only the normal WordPress fields (kses as usual) – never Divi markup;
// the layout is written by the Divi composer through DiviSaver's single kses window.
Functions\expect('wp_insert_post')->once()->with([
'post_title' => 'Start',
'post_type' => 'page',
'post_status' => 'draft',
'post_author' => 3,
], true)->andReturn(42);
$this->stubStoredDraft();
$result = $this->ability($composer)->execute(self::INPUT);
self::assertTrue($result['ok']);
self::assertSame(Rev::of(self::MARKUP), $result['tree_rev']);
self::assertSame(['s0: leer (section ohne Inhalt).'], $result['warnings']);
self::assertSame('s0 divi/section', $result['outline']);
self::assertStringContainsString('Entwurf', $result['summary'], 'drafts stay drafts');
self::assertArrayHasKey('tree_rev', $this->ability(null)->outputSchema()['properties']);
self::assertArrayHasKey('divi', $this->ability(null)->inputSchema()['properties']);
}
public function testWithoutDiviTheComposerIsNotUsed(): void
{
$composer = Mockery::mock(ContentComposer::class);
$composer->expects('available')->never();
Functions\when('wp_insert_post')->justReturn(42);
$this->stubStoredDraft();
$result = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start']);
self::assertArrayNotHasKey('tree_rev', $result);
}
public function testDiviNeedsDiviAndExcludesContent(): void
{
Functions\expect('wp_insert_post')->never();
$inactive = $this->ability($this->composer(false))->execute(self::INPUT);
self::assertToolError($inactive, 'unsupported');
$none = $this->ability(null)->execute(self::INPUT);
self::assertToolError($none, 'unsupported');
$both = $this->ability($this->composer())->execute(self::INPUT + ['content' => '<p>x</p>']);
self::assertToolError($both, 'validation_failed');
self::assertArrayHasKey('content', self::errorFields($both));
}
/**
* Security M2: with the Divi group disabled or "nur lesen", or lit/divi-insert disabled, the
* divi input is refused before anything is created; content-create without divi keeps working.
*/
public function testDiviIsRefusedWhenDiviWritesAreSwitchedOff(): void
{
Functions\expect('wp_insert_post')->never();
$error = $this->ability($this->composer(true, false))->execute(self::INPUT);
$payload = self::assertToolError($error, 'unsupported');
self::assertStringContainsString('Tools', (string) $payload['hint']);
self::assertStringContainsString('ohne divi', (string) $payload['hint']);
}
public function testWithoutDiviTheGateIsNotAsked(): void
{
$composer = Mockery::mock(ContentComposer::class);
$composer->expects('writable')->never();
Functions\when('wp_insert_post')->justReturn(42);
$this->stubStoredDraft();
$result = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start']);
self::assertTrue($result['ok']);
}
public function testEveryValidationErrorComesBackAndNothingIsCreated(): void
{
Functions\expect('wp_insert_post')->never();
$composer = Mockery::mock(ContentComposer::class);
$composer->allows('available')->andReturn(true);
$composer->allows('writable')->andReturn(true);
$composer->allows('check')->andReturn(Errors::invalidAttrPath(['divi[0].attrs.x' => 'unbekannt']));
$writer = Mockery::mock(PostWriter::class);
$writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.']));
$error = $this->ability($composer, $writer)->execute(self::INPUT);
self::assertToolError($error, 'invalid_attr_path');
self::assertSame(
['fields.role' => 'Pflichtfeld fehlt.', 'divi[0].attrs.x' => 'unbekannt'],
self::errorFields($error)
);
}
public function testAFailingLayoutWriteDiscardsTheDraft(): void
{
$composer = $this->composer();
$composer->allows('write')->andThrow(new RuntimeException('Divi flag missing'));
Functions\when('wp_insert_post')->justReturn(42);
Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42]));
try {
$this->ability($composer)->execute(self::INPUT);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
// Ruling S50 / ledger T9: the client reads about the layout, not about fields or terms.
self::assertSame(NewDraft::REASON_LAYOUT, $e->reason);
self::assertStringContainsString('Divi-Layout', $e->reason);
}
}
public function testALayoutTheNewEntryRefusesDeletesTheDraftAndReturnsTheErrors(): void
{
$composer = $this->composer();
$composer->allows('write')->andReturn(Errors::invalidInput('ungültig', ['divi[0].modules[0].text' => 'nicht angeboten']));
Functions\when('wp_insert_post')->justReturn(42);
Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42]));
$error = $this->ability($composer)->execute(self::INPUT);
self::assertInstanceOf(WP_Error::class, $error);
self::assertToolError($error, 'validation_failed');
self::assertArrayHasKey('divi[0].modules[0].text', self::errorFields($error));
}
public function testARefusedLayoutWhoseDraftCannotBeDeletedIsAPartialWrite(): void
{
$composer = $this->composer();
$composer->allows('write')->andReturn(Errors::invalidInput('ungültig', ['divi[0].modules[0].text' => 'nicht angeboten']));
Functions\when('wp_insert_post')->justReturn(42);
Functions\expect('wp_delete_post')->once()->andReturn(false);
try {
$this->ability($composer)->execute(self::INPUT);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame(42, $e->postId);
self::assertStringContainsString('divi[0].modules[0].text', $e->reason);
}
}
/**
* Rulings S40/S41 hold for content-create too – they live in the Composer: the same path via
* short form and attrs is refused (nothing created); an admin label in attrs wins over label.
*/
public function testShortFormAndAttrsRulesApplyThroughTheRealComposer(): void
{
$design = DiviWriterFixture::designReader();
$saved = [];
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('saveNew')->andReturnUsing(static function (int $postId, string $content) use (&$saved): array {
$saved[] = $content;
return [];
});
$real = new DiviContentComposer(
$this->schema(),
DiviWriterFixture::composition($design),
$saver,
$design
);
self::assertFalse($real->available(), 'no Divi classes in unit tests');
// Only available() is replaced (Divi's classes are missing here); check/write are the real ones.
$composer = new class ($real) implements ContentComposer {
public function __construct(private DiviContentComposer $real)
{
}
public function available(): bool
{
return true;
}
public function writable(): bool
{
return true;
}
public function check(string $postType, array $sections): ?WP_Error
{
return $this->real->check($postType, $sections);
}
public function write(int $postId, string $postType, array $sections): array|WP_Error
{
return $this->real->write($postId, $postType, $sections);
}
};
Functions\when('is_admin')->justReturn(false);
$this->stubStoredDraft();
Functions\expect('wp_insert_post')->never();
$overlap = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [[
'type' => 'section',
'label' => 'Kurz',
'attrs' => ['module.meta.adminLabel' => ['desktop' => 'Lang']],
'modules' => [['type' => 'button', 'text' => 'Los', 'attrs' => ['button.innerContent' => ['desktop' => ['text' => 'x']]]]],
]]]);
self::assertToolError($overlap, 'validation_failed');
$fields = self::errorFields($overlap);
self::assertArrayHasKey('divi[0].attrs.module.meta.adminLabel', $fields, 'S41: label and attrs label');
self::assertArrayHasKey('divi[0].modules[0].modules[0][0].attrs.button.innerContent', $fields, 'S40');
self::assertSame([], $saved);
Functions\expect('wp_insert_post')->once()->andReturn(42);
$label = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start', 'divi' => [[
'type' => 'section',
'label' => 'Hero',
'modules' => [['type' => 'heading', 'text' => 'Hallo', 'attrs' => ['module.meta.adminLabel' => ['desktop' => 'Aus attrs']]]],
]]]);
self::assertTrue($label['ok']);
self::assertStringContainsString('"adminLabel":{"desktop":{"value":"Aus attrs"}}', $saved[0], 'S41: no auto label over it');
}
}