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
382 lines
18 KiB
PHP
382 lines
18 KiB
PHP
<?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());
|
||
}
|
||
}
|
||
}
|