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
259 lines
12 KiB
PHP
259 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use LIT\MCP\Abilities\Cursor;
|
|
use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility;
|
|
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
|
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
|
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
|
use LIT\MCP\Tests\Unit\TestCase;
|
|
use Mockery;
|
|
use WP_Post;
|
|
|
|
final class ContentQueryAbilityTest extends TestCase
|
|
{
|
|
private function ability(?ContentSchema $schema = null): ContentQueryAbility
|
|
{
|
|
$presenter = Mockery::mock(PostPresenter::class);
|
|
$presenter->allows('summary')->andReturnUsing(static fn (WP_Post $post): array => ['id' => $post->ID]);
|
|
|
|
return new ContentQueryAbility($schema ?? Mockery::mock(ContentSchema::class), $presenter);
|
|
}
|
|
|
|
public function testBuildsTheQueryAndPaginates(): void
|
|
{
|
|
Functions\when('current_user_can')->justReturn(true);
|
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) []]);
|
|
$schema = Mockery::mock(ContentSchema::class);
|
|
$schema->allows('fields')->andReturn(['level' => new FieldDefinition('level', 'Level', 'string', 'pods', 'writable')]);
|
|
$schema->allows('taxonomies')->andReturn([['name' => 'category', 'label' => 'Kategorien', 'hierarchical' => true]]);
|
|
Functions\expect('get_posts')->once()->with(Mockery::on(static function (array $args): bool {
|
|
return $args['post_type'] === 'lit_person'
|
|
&& $args['post_status'] === ['draft']
|
|
&& $args['s'] === 'Anna'
|
|
&& $args['posts_per_page'] === 3
|
|
&& $args['offset'] === 4
|
|
&& $args['orderby'] === ['title' => 'ASC', 'ID' => 'ASC']
|
|
&& $args['tax_query'] === [
|
|
'relation' => 'OR',
|
|
['taxonomy' => 'category', 'field' => 'term_id', 'terms' => [4]],
|
|
['taxonomy' => 'category', 'field' => 'slug', 'terms' => ['news']],
|
|
]
|
|
&& $args['meta_query'] === [['key' => 'level', 'compare' => '=', 'value' => 'senior']]
|
|
&& $args['date_query'] === [['after' => '2026-01-01', 'inclusive' => true]];
|
|
}))->andReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2]), new WP_Post(['ID' => 3])]);
|
|
|
|
$result = $this->ability($schema)->execute([
|
|
'post_type' => 'lit_person',
|
|
'status' => ['draft'],
|
|
'search' => 'Anna',
|
|
'taxonomy' => ['taxonomy' => 'category', 'terms' => [4, 'news']],
|
|
'field' => ['key' => 'level', 'value' => 'senior'],
|
|
'date_after' => '2026-01-01',
|
|
'orderby' => 'title',
|
|
'order' => 'asc',
|
|
'limit' => 2,
|
|
'cursor' => Cursor::encode(4),
|
|
]);
|
|
|
|
self::assertSame([['id' => 1], ['id' => 2]], $result['items']);
|
|
self::assertSame(6, Cursor::decode($result['next_cursor']));
|
|
}
|
|
|
|
public function testExistsFilterHasNoValueAndLastPageHasNoCursor(): void
|
|
{
|
|
Functions\when('current_user_can')->justReturn(true);
|
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) []]);
|
|
$schema = Mockery::mock(ContentSchema::class);
|
|
$schema->allows('fields')->andReturn(['role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable')]);
|
|
Functions\expect('get_posts')->once()->with(Mockery::on(
|
|
static fn (array $args): bool => $args['meta_query'] === [['key' => 'role', 'compare' => 'EXISTS']]
|
|
&& $args['post_status'] === ['publish', 'draft', 'pending', 'private', 'future']
|
|
))->andReturn([new WP_Post(['ID' => 1])]);
|
|
|
|
$result = $this->ability($schema)->execute(['post_type' => 'page', 'field' => ['key' => 'role', 'compare' => 'EXISTS']]);
|
|
|
|
self::assertNull($result['next_cursor']);
|
|
}
|
|
|
|
public function testPostsTheUserCannotEditAreDropped(): void
|
|
{
|
|
Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $id !== 2);
|
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) []]);
|
|
Functions\when('get_posts')->justReturn([new WP_Post(['ID' => 1]), new WP_Post(['ID' => 2])]);
|
|
|
|
$result = $this->ability()->execute(['post_type' => 'page']);
|
|
|
|
self::assertSame([['id' => 1]], $result['items']);
|
|
}
|
|
|
|
public function testInvalidCursorIsRejected(): void
|
|
{
|
|
$result = $this->ability()->execute(['post_type' => 'page', 'cursor' => '???']);
|
|
|
|
self::assertSame('lit_validation_failed', $result->get_error_code());
|
|
}
|
|
|
|
public function testQueryIsRestrictedToOwnPostsWithoutEditOthersPosts(): 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([]);
|
|
|
|
$result = $this->ability()->execute(['post_type' => 'page']);
|
|
|
|
self::assertSame([], $result['items']);
|
|
}
|
|
|
|
public function testQueryIsNotRestrictedToAuthorWithEditOthersPosts(): void
|
|
{
|
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) []]);
|
|
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([]);
|
|
|
|
$this->ability()->execute(['post_type' => 'page']);
|
|
}
|
|
|
|
public function testDisallowedStatusesAreRemovedFromTheQuery(): void
|
|
{
|
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [
|
|
'edit_others_posts' => 'edit_others_posts',
|
|
'edit_private_posts' => 'edit_private_posts',
|
|
'edit_published_posts' => 'edit_published_posts',
|
|
]]);
|
|
Functions\when('current_user_can')->alias(
|
|
static fn (string $cap): bool => ! in_array($cap, ['edit_private_posts', 'edit_published_posts'], true)
|
|
);
|
|
Functions\expect('get_posts')->once()->with(
|
|
Mockery::on(static fn (array $args): bool => $args['post_status'] === ['draft', 'pending'])
|
|
)->andReturn([]);
|
|
|
|
$this->ability()->execute(['post_type' => 'page']);
|
|
}
|
|
|
|
public function testRequestingOnlyDisallowedStatusesReturnsEmptyWithoutQuerying(): void
|
|
{
|
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [
|
|
'edit_others_posts' => 'edit_others_posts',
|
|
'edit_published_posts' => 'edit_published_posts',
|
|
]]);
|
|
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_others_posts');
|
|
Functions\expect('get_posts')->never();
|
|
|
|
$result = $this->ability()->execute(['post_type' => 'page', 'status' => ['publish']]);
|
|
|
|
self::assertSame(['items' => [], 'next_cursor' => null], $result);
|
|
}
|
|
|
|
public function testRequestingOnlyPrivateWithoutRightsReturnsEmptyWithoutQuerying(): void
|
|
{
|
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [
|
|
'edit_others_posts' => 'edit_others_posts',
|
|
'edit_private_posts' => 'edit_private_posts',
|
|
]]);
|
|
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_others_posts');
|
|
Functions\expect('get_posts')->never();
|
|
|
|
$result = $this->ability()->execute(['post_type' => 'page', 'status' => ['private']]);
|
|
|
|
self::assertSame(['items' => [], 'next_cursor' => null], $result);
|
|
}
|
|
|
|
public function testOwnPrivatePostsStayVisibleWithoutEditPrivatePostsWhenAuthorRestricted(): void
|
|
{
|
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [
|
|
'edit_others_posts' => 'edit_others_posts',
|
|
'edit_private_posts' => 'edit_private_posts',
|
|
]]);
|
|
Functions\when('current_user_can')->justReturn(false);
|
|
Functions\when('get_current_user_id')->justReturn(9);
|
|
Functions\expect('get_posts')->once()->with(Mockery::on(
|
|
static fn (array $args): bool => $args['post_status'] === ['private'] && ($args['author'] ?? null) === 9
|
|
))->andReturn([]);
|
|
|
|
$this->ability()->execute(['post_type' => 'page', 'status' => ['private']]);
|
|
}
|
|
|
|
public function testUnknownFieldKeyIsRejected(): void
|
|
{
|
|
$schema = Mockery::mock(ContentSchema::class);
|
|
$schema->allows('fields')->andReturn(['role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable')]);
|
|
|
|
$result = $this->ability($schema)->execute(['post_type' => 'page', 'field' => ['key' => 'ghost']]);
|
|
|
|
self::assertSame('lit_validation_failed', $result->get_error_code());
|
|
self::assertStringContainsString('role', $result->get_error_message());
|
|
}
|
|
|
|
public function testHiddenFieldKeyIsRejectedLikeAnUnknownOne(): void
|
|
{
|
|
$schema = Mockery::mock(ContentSchema::class);
|
|
$schema->allows('fields')->andReturn([
|
|
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
|
|
'secret' => new FieldDefinition('secret', 'Secret', 'password', 'pods', 'writable'),
|
|
'_internal' => new FieldDefinition('_internal', 'Internal', 'string', 'meta', 'readonly'),
|
|
]);
|
|
|
|
$password = $this->ability($schema)->execute(['post_type' => 'page', 'field' => ['key' => 'secret']]);
|
|
$protected = $this->ability($schema)->execute(['post_type' => 'page', 'field' => ['key' => '_internal']]);
|
|
|
|
self::assertSame('lit_validation_failed', $password->get_error_code());
|
|
self::assertSame('Verfügbare Felder: role.', $password->get_error_data()['hint']);
|
|
self::assertSame('lit_validation_failed', $protected->get_error_code());
|
|
self::assertSame('Verfügbare Felder: role.', $protected->get_error_data()['hint']);
|
|
}
|
|
|
|
public function testUnknownTaxonomyIsRejected(): void
|
|
{
|
|
$schema = Mockery::mock(ContentSchema::class);
|
|
$schema->allows('taxonomies')->andReturn([['name' => 'category', 'label' => 'Kategorien', 'hierarchical' => true]]);
|
|
|
|
$result = $this->ability($schema)->execute([
|
|
'post_type' => 'page',
|
|
'taxonomy' => ['taxonomy' => 'ghost', 'terms' => [1]],
|
|
]);
|
|
|
|
self::assertSame('lit_validation_failed', $result->get_error_code());
|
|
self::assertStringContainsString('category', $result->get_error_message());
|
|
}
|
|
|
|
public function testPermissionNeedsAManagedTypeAndEditRights(): void
|
|
{
|
|
$schema = Mockery::mock(ContentSchema::class);
|
|
$schema->allows('isManaged')->andReturn(false);
|
|
$ability = new ContentQueryAbility($schema, Mockery::mock(PostPresenter::class));
|
|
|
|
self::assertSame('lit_not_found', $ability->permission(['post_type' => 'secret'])->get_error_code());
|
|
}
|
|
|
|
public function testPermissionIsForbiddenWithoutEditRights(): void
|
|
{
|
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_posts']]);
|
|
Functions\when('current_user_can')->justReturn(false);
|
|
$schema = Mockery::mock(ContentSchema::class);
|
|
$schema->allows('isManaged')->andReturn(true);
|
|
$ability = new ContentQueryAbility($schema, Mockery::mock(PostPresenter::class));
|
|
|
|
self::assertSame('lit_forbidden', $ability->permission(['post_type' => 'page'])->get_error_code());
|
|
}
|
|
|
|
public function testInputSchemaLimitsSearchLengthAndTaxonomyTermsCount(): void
|
|
{
|
|
$schema = $this->ability()->inputSchema();
|
|
|
|
self::assertSame(200, $schema['properties']['search']['maxLength']);
|
|
self::assertSame(50, $schema['properties']['taxonomy']['properties']['terms']['maxItems']);
|
|
}
|
|
}
|