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
This commit is contained in:
@@ -29,9 +29,16 @@ final class MediaSearchAbilityTest extends TestCase
|
||||
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'
|
||||
@@ -52,10 +59,62 @@ final class MediaSearchAbilityTest extends TestCase
|
||||
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));
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 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([]);
|
||||
@@ -63,10 +122,38 @@ final class MediaSearchAbilityTest extends TestCase
|
||||
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);
|
||||
|
||||
@@ -81,6 +168,7 @@ final class MediaSearchAbilityTest extends TestCase
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -90,10 +178,25 @@ final class MediaSearchAbilityTest extends TestCase
|
||||
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));
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 2);
|
||||
$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);
|
||||
|
||||
@@ -105,8 +208,11 @@ final class MediaSearchAbilityTest extends TestCase
|
||||
|
||||
public function testUnusedWarnsAndPagesWhenTheScanCapIsReached(): void
|
||||
{
|
||||
Functions\when('get_posts')->justReturn($this->attachments(...range(1, 21)));
|
||||
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);
|
||||
|
||||
@@ -117,6 +223,47 @@ final class MediaSearchAbilityTest extends TestCase
|
||||
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);
|
||||
|
||||
@@ -37,8 +37,7 @@ final class ContentQueryAbilityTest extends TestCase
|
||||
&& $args['s'] === 'Anna'
|
||||
&& $args['posts_per_page'] === 3
|
||||
&& $args['offset'] === 4
|
||||
&& $args['orderby'] === 'title'
|
||||
&& $args['order'] === 'ASC'
|
||||
&& $args['orderby'] === ['title' => 'ASC', 'ID' => 'ASC']
|
||||
&& $args['tax_query'] === [
|
||||
'relation' => 'OR',
|
||||
['taxonomy' => 'category', 'field' => 'term_id', 'terms' => [4]],
|
||||
|
||||
@@ -85,6 +85,27 @@ final class PostGuardTest extends TestCase
|
||||
self::assertNull(PostGuard::statusStable(new WP_Post(['post_status' => 'publish'])));
|
||||
}
|
||||
|
||||
public function testOwnPostsOnlyReturnsTheCurrentUserWithoutEditOthersPosts(): 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);
|
||||
|
||||
self::assertSame(7, PostGuard::ownPostsOnly('page'));
|
||||
}
|
||||
|
||||
public function testOwnPostsOnlyReturnsNullWithEditOthersPosts(): void
|
||||
{
|
||||
Functions\when('get_post_type_object')->justReturn(
|
||||
(object) ['cap' => (object) ['edit_others_posts' => 'edit_others_posts']]
|
||||
);
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
|
||||
self::assertNull(PostGuard::ownPostsOnly('page'));
|
||||
}
|
||||
|
||||
public function testPostTypeChecksAvailabilityAndCapability(): void
|
||||
{
|
||||
$object = (object) ['cap' => (object) ['create_posts' => 'edit_pages']];
|
||||
|
||||
Reference in New Issue
Block a user