feat: media usage finder and media-get tool

Usage search distinguishes certain (ID referenced) and possible (file URL
only) usages, hides titles the user cannot read, and keeps queries bounded.
MediaGuard checks per-attachment capabilities and refuses trashed media
unless the caller opts in.

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 01:54:55 +02:00
co-authored by Claude Opus 5.5
parent 51e8846e1f
commit a2a2da1ae7
14 changed files with 1182 additions and 3 deletions
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Media;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\MediaGetAbility;
use LIT\MCP\Integrations\Media\MediaPresenter;
use LIT\MCP\Integrations\Media\MediaUsageFinder;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class MediaGetAbilityTest extends TestCase
{
public function testReturnsMetadataWithUsages(): void
{
$attachment = new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'inherit']);
Functions\when('get_post')->justReturn($attachment);
Functions\when('current_user_can')->justReturn(true);
$finder = Mockery::mock(MediaUsageFinder::class);
$finder->expects('usages')->with(33)->andReturn([]);
$presenter = Mockery::mock(MediaPresenter::class);
$presenter->expects('full')->with($attachment, [])->andReturn(['id' => 33]);
self::assertSame(['id' => 33], (new MediaGetAbility($presenter, $finder))->execute(['id' => 33]));
}
public function testTrashedAttachmentsCanBeRead(): void
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'trash']));
Functions\when('current_user_can')->justReturn(true);
$ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class));
self::assertTrue($ability->permission(['id' => 33]));
}
public function testNonAttachmentIsNotFound(): void
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page']));
$ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class));
self::assertSame('lit_not_found', $ability->permission(['id' => 5])->get_error_code());
}
public function testIsIndirectAndReadonly(): void
{
$ability = new MediaGetAbility(Mockery::mock(MediaPresenter::class), Mockery::mock(MediaUsageFinder::class));
self::assertSame('lit/media-get', $ability->name());
self::assertFalse($ability->isDirect());
self::assertTrue($ability->annotations()['readonly']);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Media;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\MediaGuard;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
final class MediaGuardTest extends TestCase
{
public function testInvalidIdIsAValidationError(): void
{
self::assertSame('lit_validation_failed', MediaGuard::attachment('abc')->get_error_code());
self::assertSame('lit_validation_failed', MediaGuard::attachment(0)->get_error_code());
}
public function testMissingOrNonAttachmentIsNotFound(): void
{
Functions\when('get_post')->justReturn(null);
self::assertSame('lit_not_found', MediaGuard::attachment(33)->get_error_code());
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 33, 'post_type' => 'page']));
self::assertSame('lit_not_found', MediaGuard::attachment(33)->get_error_code());
}
public function testChecksTheRequestedCapabilityOnTheAttachment(): void
{
$attachment = new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'inherit']);
Functions\when('get_post')->justReturn($attachment);
Functions\expect('current_user_can')->once()->with('delete_post', 33)->andReturn(false);
self::assertSame('lit_forbidden', MediaGuard::attachment('33', 'delete_post')->get_error_code());
}
public function testRefusesTrashedAttachmentsUnlessAllowed(): void
{
$attachment = new WP_Post(['ID' => 33, 'post_type' => 'attachment', 'post_status' => 'trash']);
Functions\when('get_post')->justReturn($attachment);
Functions\when('current_user_can')->justReturn(true);
$error = MediaGuard::attachment(33);
self::assertSame('lit_validation_failed', $error->get_error_code());
self::assertStringContainsString('Papierkorb', $error->get_error_message());
self::assertSame($attachment, MediaGuard::attachment(33, 'read_post', true));
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Media;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Media\MediaIntegration;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class MediaIntegrationTest extends TestCase
{
/** Grows with Tasks 2–5 – keep in the order of abilities(). */
private const EXPECTED = [
'lit/media-get',
];
public function testProvidesTheMediaAbilities(): void
{
$integration = new MediaIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class), new Limits());
self::assertSame('media', $integration->id());
self::assertTrue($integration->isAvailable());
self::assertSame(self::EXPECTED, array_map(static fn (Ability $a): string => $a->name(), $integration->abilities()));
foreach ($integration->abilities() as $ability) {
self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
}
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Media;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\MediaPresenter;
use LIT\MCP\Tests\Unit\TestCase;
use stdClass;
use WP_Post;
final class MediaPresenterTest extends TestCase
{
private function attachment(): WP_Post
{
return new WP_Post([
'ID' => 33,
'post_type' => 'attachment',
'post_title' => 'Foto',
'post_mime_type' => 'image/jpeg',
'post_excerpt' => 'Bildunterschrift',
'post_content' => 'Beschreibung',
'post_parent' => 5,
]);
}
private function stubCommon(array $meta): void
{
Functions\when('wp_get_attachment_metadata')->justReturn($meta);
Functions\when('get_attached_file')->justReturn('/var/www/html/wp-content/uploads/2026/09/foto.jpg');
Functions\when('wp_basename')->alias('basename');
Functions\when('wp_get_attachment_url')->justReturn('https://kunde.de/wp-content/uploads/2026/09/foto.jpg');
Functions\when('get_post_meta')->justReturn('Porträt');
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=33&action=edit');
}
public function testSummaryHasFileDataAndAlt(): void
{
$this->stubCommon(['width' => 1600, 'height' => 900, 'filesize' => 123456]);
$summary = (new MediaPresenter())->summary($this->attachment());
self::assertSame('foto.jpg', $summary['filename']);
self::assertSame('Porträt', $summary['alt']);
self::assertSame(1600, $summary['width']);
self::assertSame(123456, $summary['filesize']);
}
public function testFullListsSizesAndUsagesAndCopesWithMissingMetadata(): void
{
$this->stubCommon(['sizes' => ['medium' => ['width' => 300, 'height' => 169]]]);
Functions\when('wp_get_attachment_image_src')->justReturn(['https://kunde.de/foto-300x169.jpg', 300, 169, true]);
$usages = [['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured', 'certainty' => 'sicher']];
$full = (new MediaPresenter())->full($this->attachment(), $usages);
self::assertSame(['medium' => ['url' => 'https://kunde.de/foto-300x169.jpg', 'width' => 300, 'height' => 169]], $full['sizes']);
self::assertSame($usages, $full['usages']);
self::assertSame('Bildunterschrift', $full['caption']);
self::assertSame(5, $full['parent']);
self::assertSame('https://kunde.de/wp-admin/post.php?post=33&action=edit', $full['edit_url']);
self::assertNull($full['width']);
Functions\when('wp_get_attachment_metadata')->justReturn(false);
self::assertInstanceOf(stdClass::class, (new MediaPresenter())->full($this->attachment(), [])['sizes']);
}
public function testFullSchemaRequiresSizesAndUsagesAndDescribesUsages(): void
{
$schema = MediaPresenter::fullSchema();
self::assertContains('sizes', $schema['required']);
self::assertContains('usages', $schema['required']);
self::assertSame(['sicher', 'möglicherweise'], $schema['properties']['usages']['items']['properties']['certainty']['enum']);
}
}
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Media;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\MediaUsageFinder;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
final class MediaUsageFinderTest extends TestCase
{
/** @var list<string> */
private array $queries = [];
/**
* @param array<string, mixed> $options
* @param array<int, string> $contents Candidate post ID => post_content.
* @param list<object> $featured
* @param list<object> $fields
*/
private function finder(
array $contents = [],
array $featured = [],
array $fields = [],
array $options = [],
int $customLogo = 0,
array $mediaKeys = ['photo'],
string $file = '2026/09/foto-scaled.jpg'
): MediaUsageFinder {
Functions\when('get_post_meta')->justReturn($file);
Functions\when('get_option')->alias(static fn (string $name) => $options[$name] ?? false);
Functions\when('get_theme_mod')->justReturn($customLogo);
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 13);
/** @var MockInterface&\wpdb $db */
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->allows('get_results')->andReturnUsing(function (string $sql) use ($contents, $featured, $fields): array {
$this->queries[] = $sql;
if (str_contains($sql, "_thumbnail_id")) {
return $featured;
}
if (str_contains($sql, 'pm.meta_key IN')) {
return $fields;
}
if (str_contains($sql, 'post_content LIKE')) {
return array_map(
static fn (int $id): object => (object) ['ID' => $id, 'post_title' => 'Seite ' . $id, 'post_type' => 'page'],
array_keys($contents)
);
}
if (str_contains($sql, 'p.ID IN')) {
$rows = [];
foreach ($contents as $id => $content) {
if (preg_match('/\b' . $id . '\b/', substr($sql, (int) strpos($sql, 'p.ID IN')))) {
$rows[] = (object) ['ID' => $id, 'post_content' => $content];
}
}
return $rows;
}
return [];
});
return new MediaUsageFinder(static fn (): array => $mediaKeys, $db);
}
/** @return array<int, string> post_id => certainty */
private static function certainties(array $usages): array
{
$result = [];
foreach ($usages as $usage) {
$result[$usage['post_id']] = $usage['certainty'];
}
return $result;
}
public function testCombinesFeaturedFieldContentAndSiteUsages(): void
{
$usages = $this->finder(
[5 => '<img class="wp-image-33">', 9 => '<img class="wp-image-33 size-full">'],
[(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']],
[(object) ['ID' => 12, 'post_title' => 'Anna', 'post_type' => 'lit_person']],
['site_icon' => '33'],
33
)->usages(33);
self::assertSame(
[
['post_id' => 5, 'title' => 'Team', 'post_type' => 'page', 'where' => 'featured', 'certainty' => 'sicher'],
['post_id' => 12, 'title' => 'Anna', 'post_type' => 'lit_person', 'where' => 'field', 'certainty' => 'sicher'],
['post_id' => 0, 'title' => 'Website-Icon', 'post_type' => 'option', 'where' => 'site_icon', 'certainty' => 'sicher'],
['post_id' => 0, 'title' => 'Logo (Customizer)', 'post_type' => 'theme_mod', 'where' => 'custom_logo', 'certainty' => 'sicher'],
['post_id' => 9, 'title' => 'Seite 9', 'post_type' => 'page', 'where' => 'content', 'certainty' => 'sicher'],
],
$usages
);
}
public function testQueriesArePreparedAndBounded(): void
{
$this->finder([5 => 'x'])->usages(33, 7);
[$featured, $fields, $candidates] = $this->queries;
self::assertStringContainsString("pm.meta_value = '33'", $featured);
self::assertStringContainsString('LIMIT 7', $featured);
self::assertStringContainsString("pm.meta_key IN ('photo')", $fields);
self::assertStringContainsString('LIMIT 7', $fields);
foreach (['%wp-image-33%', '%"id":33%', '%"id":"33"%', '%"mediaId":33%', '%ids=%33%', '%"ids":[%33%', '%galleryIds":%33%', '%/2026/09/foto%', '%\\\\/2026\\\\/09\\\\/foto%'] as $like) {
self::assertStringContainsString("'" . $like . "'", $candidates, $like);
}
self::assertStringContainsString('LIMIT 200', $candidates);
self::assertStringNotContainsString('post_content,', $candidates, 'candidates must not load content');
}
public function testCertainContentReferencesByAttachmentId(): void
{
$contents = [
1 => '[gallery ids="4,33,7"]',
2 => '[et_pb_gallery gallery_ids="33,8"][/et_pb_gallery]',
3 => '<!-- wp:image {"id":33,"sizeSlug":"large"} --><figure></figure><!-- /wp:image -->',
4 => '<!-- wp:gallery {"ids":[2,33]} /-->',
5 => '<!-- wp:media-text {"mediaId":33,"mediaType":"image"} --><div></div><!-- /wp:media-text -->',
6 => '<!-- wp:cover {"url":"x","id":33} --><div></div><!-- /wp:cover -->',
7 => '<!-- wp:divi/image {"image":{"innerContent":{"desktop":{"value":{"src":"https://k.de/a.jpg","id":"33"}}}}} /-->',
8 => '<!-- wp:divi/gallery {"image":{"advanced":{"galleryIds":{"desktop":{"value":"12,33"}}}}} /-->',
9 => '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"[et_pb_gallery gallery_ids=\\u002233\\u0022]"}}}} /-->',
];
$usages = $this->finder($contents, [], [], [], 0, [])->usages(33);
self::assertSame(array_fill_keys(range(1, 9), 'sicher'), self::certainties($usages));
}
public function testFileUrlMatchesAreOnlyPossibleUsages(): void
{
$contents = [
1 => '<img src="https://k.de/wp-content/uploads/2026/09/foto-300x200.jpg">',
2 => '[et_pb_image src="https://k.de/wp-content/uploads/2026/09/foto-scaled.jpg"]',
3 => '<!-- wp:divi/image {"image":{"innerContent":{"desktop":{"value":{"src":"https:\/\/k.de\/wp-content\/uploads\/2026\/09\/foto.webp"}}}}} /-->',
];
self::assertSame(
[1 => 'möglicherweise', 2 => 'möglicherweise', 3 => 'möglicherweise'],
self::certainties($this->finder($contents, [], [], [], 0, [])->usages(33))
);
}
public function testLookalikesAreNoUsage(): void
{
$contents = [
1 => '<img class="wp-image-330">',
2 => '<!-- wp:image {"id":330} /--> <!-- wp:gallery {"ids":[133,334]} /-->',
3 => '[gallery ids="330,1334"] <!-- wp:paragraph {"id":33} /-->',
4 => '<img src="https://k.de/wp-content/uploads/2026/09/foto-2.jpg"> /2025/09/foto.jpg',
];
self::assertSame([], $this->finder($contents, [], [], [], 0, [])->usages(33));
}
public function testUnreadableEntriesHideTheirTitleAndDiviLogoIsPossible(): void
{
$usages = $this->finder(
[13 => '<img class="wp-image-33">'],
[],
[],
['et_divi' => ['divi_logo' => 'https://k.de/wp-content/uploads/2026/09/foto.jpg']],
0,
[]
)->usages(33);
self::assertSame(
[
['post_id' => 0, 'title' => 'Divi-Logo', 'post_type' => 'option', 'where' => 'divi_logo', 'certainty' => 'möglicherweise'],
['post_id' => 13, 'title' => '(nicht lesbar)', 'post_type' => 'page', 'where' => 'content', 'certainty' => 'sicher'],
],
$usages
);
}
public function testIdReferenceWinsOverAFileMatchInTheSameContent(): void
{
$usages = $this->finder([5 => '/2026/09/foto.jpg <img class="wp-image-33">'], [], [], [], 0, [])->usages(33);
self::assertSame([5 => 'sicher'], self::certainties($usages));
}
public function testNoFieldQueryWithoutMediaFieldsAndUnusedMedia(): void
{
self::assertFalse($this->finder([], [], [], [], 0, [])->isUsed(33));
self::assertCount(2, $this->queries);
}
public function testIsUsedStopsBeforeTheContentScanWhenAlreadyUsed(): void
{
$finder = $this->finder([5 => 'wp-image-33'], [(object) ['ID' => 5, 'post_title' => 'Team', 'post_type' => 'page']]);
self::assertTrue($finder->isUsed(33));
self::assertCount(1, $this->queries);
}
public function testWithoutAttachedFileNoFilePatterns(): void
{
$this->finder([], [], [], [], 0, [], '')->usages(33);
self::assertStringNotContainsString('foto', $this->queries[1]);
self::assertStringNotContainsString("'%/%'", $this->queries[1]);
}
}