Files
janniclas.loosenandClaude Opus 5.5 99eda25452 fix(security): Divi-markup refusal sees content as storage will (ruling S52)
Control characters removed by kses (wp_kses_no_null: C0, \\0 sequences)
or ControlChars (DEL) could hide a divi/* delimiter or an et_pb shortcode
from the raw-input check. The shared DiviMarkup helper checks the raw
and the normalized form and now guards every raw-content path:
content-create/-update content and media-update description (attachment
post_content). MediaImporter logs import failures with scheme, host,
port and path only; uninstall.php lists the removed user meta.

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

382 lines
18 KiB
PHP
Raw Permalink 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\Integrations\WordPress\Content\PostWriter;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Integrations\WordPress\Schema\FieldSource;
use LIT\MCP\Integrations\WordPress\Schema\FieldValidator;
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
use LIT\MCP\Integrations\WordPress\Schema\TermAssigner;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Post;
final class PostWriterTest extends TestCase
{
private function stubTheme(): void
{
$theme = Mockery::mock();
$theme->allows('get_page_templates')->with(null, 'lit_person')->andReturn(['tpl-wide.php' => 'Breit']);
Functions\when('wp_get_theme')->justReturn($theme);
}
public function testPrepareCollectsAllErrorsWithPaths(): void
{
$this->stubTheme();
Functions\when('get_object_taxonomies')->justReturn([]);
Functions\when('post_type_supports')->justReturn(false);
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 3, 'post_type' => 'post']));
Functions\when('is_post_type_hierarchical')->justReturn(true);
Functions\when('get_post_ancestors')->justReturn([]);
Functions\when('current_user_can')->justReturn(true);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true),
]);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
$result = $writer->prepare('lit_person', [
'terms' => ['genre' => [1]],
'featured_image' => 9,
'parent' => 3,
'template' => 'fehlt.php',
], true);
$fields = self::errorFields($result);
self::assertToolError($result, 'validation_failed');
self::assertSame('Pflichtfeld fehlt.', $fields['fields.role']);
self::assertSame('Taxonomie gehört nicht zu diesem Inhaltstyp.', $fields['terms.genre']);
self::assertSame('Inhaltstyp unterstützt kein Beitragsbild.', $fields['featured_image']);
self::assertStringContainsString('denselben Inhaltstyp', $fields['parent']);
self::assertStringContainsString('tpl-wide.php', $fields['template']);
}
private function writerWithoutFields(): PostWriter
{
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([]);
return new PostWriter($schema, new FieldValidator(), new TermAssigner());
}
/** @param array<string, mixed> $input */
private function errorFor(PostWriter $writer, array $input, string $path): string
{
$result = $writer->prepare('page', $input, false);
self::assertFalse(is_array($result), $path . ' should be rejected');
return (string) self::errorFields($result)[$path];
}
/**
* Security M1: raw content must not introduce Divi layouts (the builder-only gates S7/S26 of
* the Divi tools would not apply). Refused are exactly the delimiters WordPress' block parser
* (and Divi's BlockParser, which extends it) accepts – "<!--" + whitespace + optional "/" +
* lower-case "wp:divi/" – plus Divi 4 shortcodes; other block markup stays allowed (kses).
*/
public function testContentWithDiviMarkupIsRefusedOnCreateAndUpdate(): void
{
$writer = $this->writerWithoutFields();
$refused = [
'<!-- wp:divi/section --><!-- /wp:divi/section -->',
"<p>x</p><!--\twp:divi/text {\"a\":1} /-->",
"<!--\n\n/wp:divi/row -->",
'[et_pb_section][/et_pb_section]',
// Ruling S52: control characters are removed before storage, so they cannot hide the markup.
"<!--\0 wp:divi/text {\"a\":1} /-->",
"<!-- wp:di\0vi/text /-->",
"<!-- wp:divi\x01/text /-->",
"[et_\0pb_section][/et_pb_section]",
'<!-- wp:di\\0vi/text /-->',
];
foreach ($refused as $content) {
foreach ([true, false] as $isCreate) {
$result = $writer->prepare('page', ['content' => $content], $isCreate);
$payload = self::assertToolError($result, 'validation_failed', $content);
self::assertStringContainsString('Divi', self::errorFields($result)['content']);
self::assertStringContainsString('`divi`', (string) $payload['hint']);
self::assertStringContainsString('lit-divi-', (string) $payload['hint']);
}
}
$allowed = [
'<!-- wp:paragraph --><p>Divi ist toll</p><!-- /wp:paragraph -->',
'<!--wp:divi/text /-->',
'<!-- WP:DIVI/text /-->',
'<p>wp:divi/ im Text</p>',
];
foreach ($allowed as $content) {
self::assertIsArray($writer->prepare('page', ['content' => $content], true), $content);
}
}
public function testParentMustBeAReadableNonDescendantEntryOfAHierarchicalType(): void
{
$writer = $this->writerWithoutFields();
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 8, 'post_type' => 'page']));
Functions\when('get_post_ancestors')->justReturn([5, 1]);
Functions\when('current_user_can')->justReturn(true);
Functions\when('is_post_type_hierarchical')->justReturn(false);
self::assertStringContainsString('hierarchisch', $this->errorFor($writer, ['parent' => 8], 'parent'));
Functions\when('is_post_type_hierarchical')->justReturn(true);
self::assertStringContainsString('selbst', $this->errorFor($writer, ['post_id' => 8, 'parent' => 8], 'parent'));
self::assertStringContainsString('untergeordnet', $this->errorFor($writer, ['post_id' => 5, 'parent' => 8], 'parent'));
self::assertIsArray($writer->prepare('page', ['post_id' => 9, 'parent' => 8], false));
Functions\when('current_user_can')->justReturn(false);
self::assertStringContainsString('Berechtigung', $this->errorFor($writer, ['parent' => 8], 'parent'));
}
public function testFeaturedImageMustBeReadable(): void
{
$writer = $this->writerWithoutFields();
Functions\when('post_type_supports')->justReturn(true);
Functions\when('wp_attachment_is_image')->justReturn(true);
Functions\expect('current_user_can')->once()->with('read_post', 9)->andReturn(false);
self::assertStringContainsString('Berechtigung', $this->errorFor($writer, ['featured_image' => 9], 'featured_image'));
}
public function testKnownDefaultAndEmptyTemplatesAreValid(): void
{
$this->stubTheme();
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([]);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
foreach (['tpl-wide.php', 'default', ''] as $template) {
self::assertIsArray($writer->prepare('lit_person', ['template' => $template], false), $template);
}
}
public function testPrepareReturnsValuesTermsAndWarnings(): void
{
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn(['city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown')]);
$result = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))
->prepare('page', ['fields' => ['city' => 'Trier']], false);
self::assertSame(['city' => 'Trier'], $result['fields']);
self::assertSame([], $result['terms']);
self::assertCount(1, $result['warnings']);
}
public function testMetaFieldsNeedTheRightToEditThatMetaKeyOnUpdate(): void
{
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
'secret' => new FieldDefinition('secret', 'secret', 'string', 'meta', 'writable'),
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
]);
$checked = [];
Functions\when('current_user_can')->alias(static function (string $cap, ...$args) use (&$checked): bool {
$checked[] = array_merge([$cap], $args);
return $args !== [5, 'secret'];
});
$result = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))->prepare(
'page',
['post_id' => 5, 'fields' => ['city' => 'Trier', 'secret' => 'x', 'role' => 'Leitung']],
false
);
self::assertSame(['fields.secret' => 'Keine Berechtigung, dieses Feld zu ändern.'], self::errorFields($result));
self::assertSame([['edit_post_meta', 5, 'city'], ['edit_post_meta', 5, 'secret']], $checked);
}
public function testCreateChecksMetaRightsOnlyOnceTheDraftExists(): void
{
Functions\when('get_object_taxonomies')->justReturn([]);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
]);
Functions\expect('current_user_can')->once()->with('add_post_meta', 42, 'city')->andReturn(false);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
self::assertIsArray($writer->prepare('page', ['fields' => ['city' => 'Trier', 'role' => 'x']], true));
self::assertSame(
['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.'],
$writer->deniedMeta(42, 'page', ['city' => 'Trier', 'role' => 'x'], 'add_post_meta')
);
}
public function testPostDataMapsOnlyGivenKeys(): void
{
$writer = new PostWriter(Mockery::mock(ContentSchema::class), new FieldValidator(), new TermAssigner());
// wp_insert_post() ignores an empty page_template, so "empty = default template" is sent as 'default'.
self::assertSame(
['post_title' => 'Team', 'post_name' => 'team', 'page_template' => 'default'],
$writer->postData(['title' => 'Team', 'slug' => 'team', 'template' => '', 'fields' => []])
);
self::assertTrue($writer->hasExtras(['featured_image' => null]));
self::assertFalse($writer->hasExtras(['title' => 'x', 'fields' => []]));
}
public function testWriteExtrasGroupsFieldsBySourceAndHandlesTheFeaturedImage(): void
{
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
$city = new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown');
$definitions = ['city' => $city, 'role' => $role];
$pods = Mockery::mock(FieldSource::class);
$pods->expects('write')->with(5, 'lit_person', ['role' => 'Leitung'], $definitions);
$sample = Mockery::mock(FieldSource::class);
$sample->expects('write')->with(5, 'lit_person', ['city' => 'Trier'], $definitions);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn($definitions);
$schema->allows('sourceOf')->andReturnUsing(static fn (FieldDefinition $f) => $f->source === 'pods' ? $pods : $sample);
Functions\expect('delete_post_thumbnail')->once()->with(5);
(new PostWriter($schema, new FieldValidator(), new TermAssigner()))->writeExtras(
5,
'lit_person',
['fields' => ['role' => 'Leitung', 'city' => 'Trier'], 'terms' => [], 'warnings' => []],
['featured_image' => 0]
);
}
public function testAFeaturedImageThatDidNotStickThrows(): void
{
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn([]);
Functions\when('set_post_thumbnail')->justReturn(false);
Functions\when('get_post_thumbnail_id')->justReturn(0);
$this->expectException(RuntimeException::class);
(new PostWriter($schema, new FieldValidator(), new TermAssigner()))->writeExtras(
5,
'lit_person',
['fields' => [], 'terms' => [], 'warnings' => []],
['featured_image' => 9]
);
}
public function testSnapshotKeepsTheOldValuesOfEverythingThatWillBeWrittenAndRestoreWritesThemBack(): void
{
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
$city = new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown');
$definitions = ['city' => $city, 'role' => $role];
$pods = Mockery::mock(FieldSource::class);
$pods->allows('read')->with(5, $role)->andReturn('Alt');
$pods->expects('write')->with(5, 'lit_person', ['role' => 'Alt'], $definitions);
$sample = Mockery::mock(FieldSource::class);
$sample->allows('read')->with(5, $city)->andReturn('');
$sample->expects('write')->with(5, 'lit_person', ['city' => ''], $definitions);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn($definitions);
$schema->allows('sourceOf')->andReturnUsing(static fn (FieldDefinition $f) => $f->source === 'pods' ? $pods : $sample);
Functions\expect('wp_get_object_terms')->once()->with(5, 'category', ['fields' => 'ids'])->andReturn(['2', 3]);
Functions\when('get_post_thumbnail_id')->justReturn(7);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
$snapshot = $writer->snapshot(
5,
'lit_person',
['fields' => ['role' => 'Neu', 'city' => 'Trier'], 'terms' => ['category' => [4]], 'warnings' => []],
['featured_image' => 9]
);
self::assertSame(
['fields' => ['role' => 'Alt', 'city' => ''], 'terms' => ['category' => [2, 3]], 'featured_image' => 7],
$snapshot
);
Functions\expect('wp_set_object_terms')->once()->with(5, [2, 3], 'category', false)->andReturn([2, 3]);
Functions\expect('set_post_thumbnail')->once()->with(5, 7)->andReturn(true);
$writer->restore(5, 'lit_person', $snapshot);
}
public function testSnapshotTellsAbsentMetaFromStoredEmptyValues(): void
{
$kept = new FieldDefinition('kept', 'kept', 'string', 'meta', 'writable');
$absent = new FieldDefinition('absent', 'absent', 'string', 'meta', 'writable');
$flag = new FieldDefinition('flag', 'flag', 'boolean', 'pods', 'writable');
$pods = Mockery::mock(FieldSource::class);
$pods->allows('read')->andReturn(false);
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn(['absent' => $absent, 'flag' => $flag, 'kept' => $kept]);
$schema->allows('sourceOf')->andReturnUsing(
static fn (FieldDefinition $f) => $f->source === 'pods' ? $pods : new RegisteredMetaSource()
);
Functions\when('get_post_meta')->justReturn('');
Functions\when('metadata_exists')->alias(static fn (string $type, int $id, string $key): bool => $key === 'kept');
$snapshot = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))->snapshot(
5,
'lit_person',
['fields' => ['kept' => 'x', 'absent' => 'y', 'flag' => true], 'terms' => [], 'warnings' => []],
[]
);
self::assertSame(['kept' => '', 'absent' => null, 'flag' => false], $snapshot['fields']);
}
public function testTermsCreatedByWriteExtrasCanBeDeletedAgain(): void
{
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
$pods = Mockery::mock(FieldSource::class);
$pods->allows('write')->andThrow(new RuntimeException('pods kaputt'));
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn(['role' => $role]);
$schema->allows('sourceOf')->andReturn($pods);
Functions\when('wp_insert_term')->justReturn(['term_id' => 15]);
Functions\when('wp_set_object_terms')->justReturn([15]);
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
try {
$writer->writeExtras(
5,
'lit_person',
['fields' => ['role' => 'x'], 'terms' => ['category' => ['Neu']], 'warnings' => []],
[]
);
self::fail('RuntimeException expected');
} catch (RuntimeException $e) {
self::assertSame('pods kaputt', $e->getMessage());
}
Functions\expect('wp_delete_term')->once()->with(15, 'category')->andReturn(true);
$writer->deleteCreatedTerms();
$writer->deleteCreatedTerms(); // nothing left – no second call
}
public function testRestoreTriesEveryStepAndReportsFailures(): void
{
$role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
$pods = Mockery::mock(FieldSource::class);
$pods->expects('write')->andThrow(new RuntimeException('pods kaputt'));
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('fields')->andReturn(['role' => $role]);
$schema->allows('sourceOf')->andReturn($pods);
Functions\expect('wp_set_object_terms')->once()->andReturn([2]);
Functions\expect('delete_post_thumbnail')->once()->andReturn(true);
try {
(new PostWriter($schema, new FieldValidator(), new TermAssigner()))->restore(
5,
'lit_person',
['fields' => ['role' => 'Alt'], 'terms' => ['category' => [2]], 'featured_image' => 0]
);
self::fail('RuntimeException expected');
} catch (RuntimeException $e) {
self::assertStringContainsString('pods kaputt', $e->getMessage());
}
}
}