fix: empty status filter, hidden-field probing, own private posts
Review fix round 2: content-query no longer lets get_posts() fall back to 'publish' when every requested status is capability-gated out (now short-circuits to an empty page). Its field filter shares PostPresenter's hidden-field rule (Pods password type, protected meta) so a hidden key is rejected like an unknown one instead of reaching the query. Password fields are now type-checked as strings on write. A user without edit_private_posts can still query their own private posts when the query is already author-restricted, matching WordPress core. 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:
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -28,7 +29,7 @@ final class ContentQueryAbilityTest extends TestCase
|
||||
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' => 'x']);
|
||||
$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'
|
||||
@@ -69,7 +70,7 @@ final class ContentQueryAbilityTest extends TestCase
|
||||
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' => 'x']);
|
||||
$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']
|
||||
@@ -142,10 +143,53 @@ final class ContentQueryAbilityTest extends TestCase
|
||||
$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' => 'x']);
|
||||
$schema->allows('fields')->andReturn(['role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable')]);
|
||||
|
||||
$result = $this->ability($schema)->execute(['post_type' => 'page', 'field' => ['key' => 'ghost']]);
|
||||
|
||||
@@ -153,6 +197,24 @@ final class ContentQueryAbilityTest extends TestCase
|
||||
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);
|
||||
|
||||
@@ -31,6 +31,7 @@ final class FieldValidatorTest extends TestCase
|
||||
'start' => new FieldDefinition('start', 'Beginn', 'date', 'pods', 'writable'),
|
||||
'secret' => new FieldDefinition('_secret', '_secret', 'string', 'meta', 'readonly'),
|
||||
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
|
||||
'pin' => new FieldDefinition('pin', 'PIN', 'password', 'pods', 'writable'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -90,6 +91,14 @@ final class FieldValidatorTest extends TestCase
|
||||
self::assertSame('5 ist kein Medium der Mediathek.', $errors['photos']);
|
||||
}
|
||||
|
||||
public function testPasswordFieldsAreValidatedAsStrings(): void
|
||||
{
|
||||
$validator = new FieldValidator();
|
||||
|
||||
self::assertSame([], $validator->validate($this->definitions(), ['pin' => '1234'], false)['errors']);
|
||||
self::assertSame('Text erwartet.', $validator->validate($this->definitions(), ['pin' => 1234], false)['errors']['pin']);
|
||||
}
|
||||
|
||||
public function testUnknownTypedFieldsAreAcceptedWithAWarningAndNullClears(): void
|
||||
{
|
||||
$result = (new FieldValidator())->validate($this->definitions(), ['city' => ['any' => 'thing'], 'level' => null], false);
|
||||
|
||||
Reference in New Issue
Block a user