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
This commit is contained in:
2026-09-26 19:16:35 +02:00
co-authored by Claude Opus 5.5
parent 7d0837be6c
commit 5f596b8a17
10 changed files with 169 additions and 6 deletions
@@ -56,10 +56,11 @@ final class ContentCreateDiviTest extends TestCase
}
/** @return ContentComposer&MockInterface */
private function composer(bool $available = true): ContentComposer
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;
@@ -139,11 +140,39 @@ final class ContentCreateDiviTest extends TestCase
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.']));
@@ -238,6 +267,11 @@ final class ContentCreateDiviTest extends TestCase
return true;
}
public function writable(): bool
{
return true;
}
public function check(string $postType, array $sections): ?WP_Error
{
return $this->real->check($postType, $sections);