Files
WP-LIT-MCP/tests/Unit/Integrations/Media/MediaSearchAbilityTest.php
T
janniclas.loosenandClaude Opus 5.5 b0da2cdecc fix: restrict media-search to the query, not just editable() after it
I1: query the attachments themselves author-restricted when the user
lacks edit_others_posts (PostGuard::ownPostsOnly, extracted from
ContentQueryAbility so content-query reuses the exact same rule)
instead of relying solely on a post-query editable() filter, which
could leak the existence of other users' attachments via an empty
page plus next_cursor. editable() stays as a backstop.

Also: M1 document that only status=inherit attachments are listed;
M2 tie-break ordering by ID (content-query and media-search) for
stable pagination; M3 no next_cursor once the last candidate was
examined; M4 reject an explicit non-image mime with missing_alt;
M5 additional coverage (posts_per_page, cursor offset pass-through,
invalid cursor, post_status/date/mime args); mime maxLength 100.

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

276 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Media;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Integrations\Media\MediaPresenter;
use LIT\MCP\Integrations\Media\MediaSearchAbility;
use LIT\MCP\Integrations\Media\MediaUsageFinder;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class MediaSearchAbilityTest extends TestCase
{
private function presenter(): MediaPresenter
{
$presenter = Mockery::mock(MediaPresenter::class);
$presenter->allows('summary')->andReturnUsing(static fn (WP_Post $a): array => ['id' => $a->ID]);
return $presenter;
}
/** @return list<WP_Post> */
private function attachments(int ...$ids): array
{
return array_map(static fn (int $id): WP_Post => new WP_Post(['ID' => $id, 'post_type' => 'attachment']), $ids);
}
/** No edit_others_posts cap mapped: PostGuard::ownPostsOnly() falls back to the raw name. */
private function unrestrictedPostType(): void
{
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) []]);
}
public function testMissingAltLimitsToImagesWithoutAltText(): void
{
Functions\when('current_user_can')->justReturn(true);
$this->unrestrictedPostType();
Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $args): bool =>
$args['post_type'] === 'attachment'
&& $args['post_mime_type'] === 'image'
&& $args['s'] === 'team'
&& $args['meta_query'] === [
'relation' => 'OR',
['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'],
['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='],
]
&& $args['posts_per_page'] === 21
&& $args['offset'] === 0))->andReturn($this->attachments(1, 2));
$result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))
->execute(['search' => 'team', 'missing_alt' => true]);
self::assertSame([['id' => 1], ['id' => 2]], $result['items']);
self::assertNull($result['next_cursor']);
self::assertSame([], $result['warnings']);
}
public function testMissingAltRejectsNonImageMime(): void
{
Functions\when('current_user_can')->justReturn(true);
$this->unrestrictedPostType();
$result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))
->execute(['missing_alt' => true, 'mime' => 'application/pdf']);
self::assertSame('lit_validation_failed', $result->get_error_code());
}
public function testMissingAltAllowsAnImageSubtypeMime(): void
{
Functions\when('current_user_can')->justReturn(true);
$this->unrestrictedPostType();
Functions\when('get_posts')->justReturn([]);
$result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))
->execute(['missing_alt' => true, 'mime' => 'image/png']);
self::assertSame([], $result['items']);
}
public function testQueryArgsCoverStatusDateAndMime(): void
{
Functions\when('current_user_can')->justReturn(true);
$this->unrestrictedPostType();
Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $args): bool =>
$args['post_status'] === 'inherit'
&& $args['post_mime_type'] === 'application/pdf'
&& $args['date_query'] === [['after' => '2026-01-01', 'before' => '2026-02-01', 'inclusive' => true]]
&& $args['posts_per_page'] === 6
&& $args['offset'] === 3))->andReturn([]);
(new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))->execute([
'mime' => 'application/pdf',
'date_after' => '2026-01-01',
'date_before' => '2026-02-01',
'limit' => 5,
'cursor' => Cursor::encode(3),
]);
}
public function testInvalidCursorIsRejected(): void
{
$result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))
->execute(['cursor' => '???']);
self::assertSame('lit_validation_failed', $result->get_error_code());
}
public function testExcludesAttachmentsTheUserCannotEdit(): void
{
Functions\when('get_posts')->justReturn($this->attachments(1, 2));
$this->unrestrictedPostType();
Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2);
$result = (new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))
->execute([]);
self::assertSame([['id' => 1]], $result['items']);
}
public function testSearchIsRestrictedToOwnAttachmentsWithoutEditOthersPosts(): void
{
Functions\when('get_post_type_object')->justReturn(
(object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']]
);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts');
Functions\when('get_current_user_id')->justReturn(7);
Functions\expect('get_posts')->once()->with(
Mockery::on(static fn (array $args): bool => ($args['author'] ?? null) === 7)
)->andReturn([]);
(new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))->execute([]);
}
public function testSearchIsNotRestrictedToAuthorWithEditOthersPosts(): void
{
Functions\when('get_post_type_object')->justReturn(
(object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']]
);
Functions\when('current_user_can')->justReturn(true);
Functions\expect('get_posts')->once()->with(
Mockery::on(static fn (array $args): bool => ! array_key_exists('author', $args))
)->andReturn([]);
(new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))->execute([]);
}
public function testUnusedFiltersThroughTheUsageFinder(): void
{
Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3));
Functions\when('current_user_can')->justReturn(true);
$this->unrestrictedPostType();
$finder = Mockery::mock(MediaUsageFinder::class);
$finder->allows('isUsed')->andReturnUsing(static fn (int $id): bool => $id === 2);
$result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]);
self::assertSame([['id' => 1], ['id' => 3]], $result['items']);
self::assertNull($result['next_cursor']);
self::assertSame([], $result['warnings']);
}
public function testUnusedStopsAtTheLimitWithACursor(): void
{
Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3));
Functions\when('current_user_can')->justReturn(true);
$this->unrestrictedPostType();
$finder = Mockery::mock(MediaUsageFinder::class);
$finder->allows('isUsed')->andReturn(false);
$result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'limit' => 1]);
self::assertSame([['id' => 1]], $result['items']);
self::assertSame(1, Cursor::decode($result['next_cursor']));
}
public function testUnusedHasNoCursorWhenTheLastCandidateWasExaminedAtTheLimit(): void
{
Functions\when('get_posts')->justReturn($this->attachments(1));
Functions\when('current_user_can')->justReturn(true);
$this->unrestrictedPostType();
$finder = Mockery::mock(MediaUsageFinder::class);
$finder->allows('isUsed')->andReturn(false);
$result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'limit' => 1]);
self::assertSame([['id' => 1]], $result['items']);
self::assertNull($result['next_cursor']);
}
public function testUnusedSkipsAttachmentsTheUserCannotEdit(): void
{
Functions\when('get_posts')->justReturn($this->attachments(1, 2, 3));
$this->unrestrictedPostType();
Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2);
$finder = Mockery::mock(MediaUsageFinder::class);
$finder->allows('isUsed')->andReturn(false);
$result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]);
self::assertSame([['id' => 1], ['id' => 3]], $result['items']);
self::assertNull($result['next_cursor']);
}
public function testUnusedWarnsAndPagesWhenTheScanCapIsReached(): void
{
Functions\when('current_user_can')->justReturn(true);
$this->unrestrictedPostType();
Functions\expect('get_posts')->once()->with(
Mockery::on(static fn (array $args): bool => $args['posts_per_page'] === 21 && $args['offset'] === 0)
)->andReturn($this->attachments(...range(1, 21)));
$finder = Mockery::mock(MediaUsageFinder::class);
$finder->allows('isUsed')->andReturn(true);
$result = (new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true]);
self::assertSame([], $result['items']);
self::assertSame(20, Cursor::decode($result['next_cursor']));
self::assertNotSame([], $result['warnings']);
}
public function testUnusedCursorOffsetIsPassedThrough(): void
{
Functions\when('current_user_can')->justReturn(true);
$this->unrestrictedPostType();
Functions\expect('get_posts')->once()->with(
Mockery::on(static fn (array $args): bool => $args['offset'] === 5)
)->andReturn([]);
$finder = Mockery::mock(MediaUsageFinder::class);
(new MediaSearchAbility($this->presenter(), $finder))->execute(['unused' => true, 'cursor' => Cursor::encode(5)]);
}
public function testUnusedIsRestrictedToOwnAttachmentsWithoutEditOthersPosts(): void
{
Functions\when('get_post_type_object')->justReturn(
(object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']]
);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts');
Functions\when('get_current_user_id')->justReturn(7);
Functions\expect('get_posts')->once()->with(
Mockery::on(static fn (array $args): bool => ($args['author'] ?? null) === 7)
)->andReturn([]);
(new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))
->execute(['unused' => true]);
}
public function testUnusedIsNotRestrictedToAuthorWithEditOthersPosts(): void
{
Functions\when('get_post_type_object')->justReturn(
(object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']]
);
Functions\when('current_user_can')->justReturn(true);
Functions\expect('get_posts')->once()->with(
Mockery::on(static fn (array $args): bool => ! array_key_exists('author', $args))
)->andReturn([]);
(new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class)))
->execute(['unused' => true]);
}
public function testRequiresUploadRights(): void
{
Functions\when('current_user_can')->justReturn(false);
$ability = new MediaSearchAbility($this->presenter(), Mockery::mock(MediaUsageFinder::class));
self::assertSame('lit_forbidden', $ability->permission([])->get_error_code());
self::assertTrue($ability->isDirect());
}
}