Files
WP-LIT-MCP/tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php
T
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

286 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\Media;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Media\MediaUpdateAbility;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
use WP_Error;
use WP_Post;
final class MediaUpdateAbilityTest extends TestCase
{
/** @var list<array<string, mixed>> */
private array $postUpdates = [];
/** @var list<array{int, string}> */
private array $altUpdates = [];
protected function setUp(): void
{
parent::setUp();
$this->postUpdates = [];
$this->altUpdates = [];
$posts = [
33 => new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => 'Foto',
'post_excerpt' => 'Alt-Unterschrift', 'post_modified_gmt' => '2026-09-25 08:00:00']),
34 => new WP_Post(['ID' => 34, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => 'Logo',
'post_modified_gmt' => '2026-09-25 09:00:00']),
35 => new WP_Post(['ID' => 35, 'post_type' => 'attachment', 'post_status' => 'trash', 'post_title' => 'Alt']),
5 => new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Seite']),
];
Functions\when('get_post')->alias(static fn ($id) => $posts[(int) $id] ?? null);
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_kses_post')->alias(static fn ($value): string => strip_tags((string) $value, '<a><em><strong>'));
Functions\when('metadata_exists')->justReturn(true);
Functions\when('get_post_meta')->justReturn('alter Alt-Text');
Functions\when('wp_update_post')->alias(function (array $data, bool $wpError) {
$this->postUpdates[] = $data;
return (int) $data['ID'];
});
Functions\when('update_post_meta')->alias(function (int $id, string $key, $value) {
$this->altUpdates[] = [$id, $value];
return true;
});
}
/** For paths that must not log. */
private function log(): ChangeLog
{
return FakeChangeLog::forbidden();
}
public function testUpdatesEveryItemAndReportsPerItem(): void
{
$log = new FakeChangeLog(41);
$result = (new MediaUpdateAbility($log))->execute(['items' => [
['id' => 33, 'alt' => 'Team vor dem Haus', 'caption' => 'Unser <script>x</script>Team'],
['id' => 34, 'title' => '<b>Firmen</b>logo', 'modified' => '2026-09-25 09:00:00'],
]]);
self::assertSame([['post_excerpt' => 'Unser xTeam', 'ID' => 33], ['post_title' => 'Firmenlogo', 'ID' => 34]], $this->postUpdates);
self::assertSame([[33, 'Team vor dem Haus']], $this->altUpdates);
self::assertSame(['id' => 33, 'ok' => true, 'error' => null, 'log_id' => 41, 'modified' => '2026-09-25 08:00:00'], $result['results'][0]);
self::assertSame(42, $result['results'][1]['log_id']);
self::assertSame(['id' => '34', 'type' => 'attachment', 'action' => 'updated', 'label' => 'Firmenlogo'], $result['changed'][1]);
self::assertSame('2 von 2 Medien aktualisiert.', $result['summary']);
$log->assertRecorded(
['ability' => 'lit/media-update', 'object_type' => 'attachment', 'object_id' => '33',
'summary' => 'Metadaten von "Foto" geändert: alt, caption'],
['ability' => 'lit/media-update', 'object_type' => 'attachment', 'object_id' => '34',
'summary' => 'Metadaten von "Logo" geändert: title']
);
}
public function testAnInvalidItemRefusesTheWholeBatchAndNamesEveryFailure(): void
{
$result = (new MediaUpdateAbility($this->log()))->execute(['items' => [
['id' => 33, 'alt' => 'ok'],
['id' => 99, 'alt' => 'x'],
['id' => 5, 'alt' => 'x'],
['id' => 35, 'alt' => 'x'],
]]);
self::assertInstanceOf(WP_Error::class, $result);
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('3 von 4', $result->get_error_message());
self::assertStringContainsString('nichts wurde gespeichert', $result->get_error_message());
$failures = $result->get_error_data()['failures'];
self::assertSame([1, 2, 3], array_column($failures, 'index'));
self::assertSame(['not_found', 'not_found', 'validation_failed'], array_column($failures, 'code'));
self::assertSame([99, 5, 35], array_column($failures, 'id'));
self::assertStringContainsString('nicht gefunden', $failures[0]['error']);
self::assertStringContainsString('Papierkorb', $failures[2]['error']);
self::assertSame([], $this->postUpdates);
self::assertSame([], $this->altUpdates);
}
public function testFailuresOfOneKindKeepTheirCodeAndReportTheNormalizedId(): void
{
$result = (new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => '99', 'alt' => 'x']]]);
self::assertToolError($result, 'not_found');
self::assertSame(99, $result->get_error_data()['failures'][0]['id']);
}
public function testRejectsEmptyDuplicateAndOverlongItems(): void
{
$result = (new MediaUpdateAbility($this->log()))->execute(['items' => [
['id' => 33],
['id' => 34, 'alt' => str_repeat('a', 501)],
['id' => 34, 'title' => 'doppelt'],
['id' => 33, 'caption' => ['kein', 'Text']],
]]);
$failures = $result->get_error_data()['failures'];
self::assertSame([0, 1, 2, 3], array_column($failures, 'index'));
self::assertStringContainsString('Nichts zu ändern', $failures[0]['error']);
self::assertStringContainsString('alt', $failures[1]['error']);
self::assertStringContainsString('mehrfach', $failures[2]['error']);
self::assertStringContainsString('caption', $failures[3]['error']);
self::assertSame([], $this->postUpdates);
}
public function testStaleModifiedRefusesTheBatchWithRevMismatch(): void
{
$result = (new MediaUpdateAbility($this->log()))->execute(['items' => [
['id' => 33, 'alt' => 'neu', 'modified' => '2026-09-25 08:00:00'],
['id' => 34, 'alt' => 'neu', 'modified' => '2026-09-24 07:00:00'],
]]);
self::assertToolError($result, 'rev_mismatch');
self::assertSame(1, $result->get_error_data()['failures'][0]['index']);
self::assertStringContainsString('lit/media-get', $result->get_error_data()['hint']);
self::assertSame([], $this->altUpdates);
}
public function testAFailedWriteRestoresTheItemsAlreadyChanged(): void
{
Functions\when('wp_update_post')->alias(function (array $data, bool $wpError) {
$this->postUpdates[] = $data;
return $data['ID'] === 34 && ($data['post_title'] ?? '') === 'Neu'
? new WP_Error('db', 'Datenbankfehler')
: (int) $data['ID'];
});
try {
(new MediaUpdateAbility($this->log()))->execute(['items' => [
['id' => 33, 'alt' => 'neu', 'caption' => 'neu'],
['id' => 34, 'title' => 'Neu'],
]]);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertToolError($e->toError(), 'write_failed');
self::assertStringContainsString('neu lesen', $e->toError()->get_error_data()['hint']);
self::assertStringContainsString('Datenbankfehler', $e->getMessage());
}
// Item 34 was refused before anything was written; item 33 gets caption and alt back.
self::assertSame([
['post_excerpt' => 'neu', 'ID' => 33],
['post_title' => 'Neu', 'ID' => 34],
['post_excerpt' => 'Alt-Unterschrift', 'ID' => 33],
], $this->postUpdates);
self::assertSame([[33, 'neu'], [33, 'alter Alt-Text']], $this->altUpdates);
}
public function testAHookThrowingAfterTheWriteAlsoRestoresThatItem(): void
{
Functions\when('wp_update_post')->alias(function (array $data, bool $wpError) {
$this->postUpdates[] = $data;
if (($data['post_title'] ?? '') === 'Neu') {
throw new RuntimeException('save_post hook failed');
}
return (int) $data['ID'];
});
$this->expectException(WriteFailed::class);
try {
(new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => 34, 'title' => 'Neu']]]);
} finally {
self::assertSame([['post_title' => 'Neu', 'ID' => 34], ['post_title' => 'Logo', 'ID' => 34]], $this->postUpdates);
}
}
public function testRestoreRemovesAnAltTextThatDidNotExistBefore(): void
{
Functions\when('metadata_exists')->justReturn(false);
Functions\when('wp_update_post')->alias(static fn (array $data) => $data['ID'] === 34
? new WP_Error('db', 'Datenbankfehler')
: (int) $data['ID']);
Functions\expect('delete_post_meta')->once()->with(33, '_wp_attachment_image_alt')->andReturn(true);
$this->expectException(WriteFailed::class);
(new MediaUpdateAbility($this->log()))->execute(['items' => [
['id' => 33, 'alt' => 'neu'],
['id' => 34, 'title' => 'Neu'],
]]);
}
public function testAFailedRestoreIsAPartialWriteNamingTheChangedIds(): void
{
Functions\when('wp_update_post')->alias(static fn (array $data) => $data['ID'] === 34
? new WP_Error('db', 'Datenbankfehler')
: (int) $data['ID']);
Functions\when('update_post_meta')->alias(static function (int $id, string $key, $value) {
if ($value === 'alter Alt-Text') {
throw new RuntimeException('meta write failed');
}
return true;
});
$log = FakeChangeLog::partialsOnly();
try {
(new MediaUpdateAbility($log))->execute(['items' => [
['id' => 33, 'alt' => 'neu'],
['id' => 34, 'title' => 'Neu'],
]]);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
$error = $e->toError();
self::assertToolError($error, 'partial_write');
self::assertSame([33], $error->get_error_data()['changed_ids']);
self::assertStringContainsString('33', $error->get_error_message());
}
$log->assertPartials(['ability' => 'lit/media-update', 'object_type' => 'attachment', 'object_id' => '33']);
}
public function testChangeLogFailureIsOnlyAWarning(): void
{
$log = new FakeChangeLog(1, new RuntimeException('db down'));
// error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead.
$file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $file);
try {
$result = (new MediaUpdateAbility($log))->execute(['items' => [['id' => 33, 'alt' => 'neu']]]);
} finally {
ini_set('error_log', (string) $previous);
unlink($file);
}
self::assertTrue($result['results'][0]['ok']);
self::assertNull($result['results'][0]['log_id']);
self::assertCount(1, $result['warnings']);
$log->assertRecorded([]);
}
public function testIsIdempotentIndirectAndNotDestructive(): void
{
$ability = new MediaUpdateAbility($this->log());
self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => true], $ability->annotations());
self::assertFalse($ability->isDirect());
self::assertSame(50, $ability->inputSchema()['properties']['items']['maxItems']);
self::assertStringContainsString('modified', $ability->description());
}
/** Ruling S52: description is the attachment's post_content – no Divi markup there either. */
public function testDescriptionRefusesDiviMarkupEvenWithControlCharacters(): void
{
foreach (["<!-- wp:divi/text /-->", "<!--\0 wp:divi/text /-->", "[et_\x01pb_text]x[/et_pb_text]"] as $description) {
$result = (new MediaUpdateAbility($this->log()))->execute(['items' => [['id' => 33, 'description' => $description]]]);
self::assertToolError($result, 'validation_failed', $description);
self::assertStringContainsString('Divi', $result->get_error_data()['failures'][0]['error']);
}
self::assertSame([], $this->postUpdates);
(new MediaUpdateAbility(FakeChangeLog::any(9)))->execute(['items' => [['id' => 33, 'description' => '<em>Team</em>']]]);
self::assertSame([['post_content' => '<em>Team</em>', 'ID' => 33]], $this->postUpdates);
}
}