fix: restrict content-query by cap before pagination, filter hidden fields
Review fix round 1: content-query now excludes posts/statuses the user cannot edit before querying (author restriction without edit_others_posts, status filtering by edit_private_posts/edit_published_posts) instead of only dropping them after fetching, and rejects unknown field/taxonomy filters. content-types' not-found hint only lists editable types. PostPresenter skips Pods password fields and readonly protected meta. Query input gains search/terms length limits. 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:
@@ -15,17 +15,21 @@ use WP_Post;
|
||||
|
||||
final class ContentQueryAbilityTest extends TestCase
|
||||
{
|
||||
private function ability(): ContentQueryAbility
|
||||
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(Mockery::mock(ContentSchema::class), $presenter);
|
||||
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' => 'x']);
|
||||
$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']
|
||||
@@ -43,7 +47,7 @@ final class ContentQueryAbilityTest extends TestCase
|
||||
&& $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()->execute([
|
||||
$result = $this->ability($schema)->execute([
|
||||
'post_type' => 'lit_person',
|
||||
'status' => ['draft'],
|
||||
'search' => 'Anna',
|
||||
@@ -63,12 +67,15 @@ final class ContentQueryAbilityTest extends TestCase
|
||||
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' => 'x']);
|
||||
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()->execute(['post_type' => 'page', 'field' => ['key' => 'role', 'compare' => 'EXISTS']]);
|
||||
$result = $this->ability($schema)->execute(['post_type' => 'page', 'field' => ['key' => 'role', 'compare' => 'EXISTS']]);
|
||||
|
||||
self::assertNull($result['next_cursor']);
|
||||
}
|
||||
@@ -76,6 +83,7 @@ final class ContentQueryAbilityTest extends TestCase
|
||||
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']);
|
||||
@@ -90,6 +98,75 @@ final class ContentQueryAbilityTest extends TestCase
|
||||
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 testUnknownFieldKeyIsRejected(): void
|
||||
{
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('fields')->andReturn(['role' => 'x']);
|
||||
|
||||
$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 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);
|
||||
@@ -98,4 +175,23 @@ final class ContentQueryAbilityTest extends TestCase
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ final class ContentTypesAbilityTest extends TestCase
|
||||
|
||||
public function testUnknownTypeIsNotFoundWithTheAvailableTypes(): void
|
||||
{
|
||||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_posts']]);
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(false);
|
||||
$schema->allows('describe')->andReturn(null);
|
||||
@@ -66,6 +68,23 @@ final class ContentTypesAbilityTest extends TestCase
|
||||
self::assertStringContainsString('page, post', $result->get_error_message());
|
||||
}
|
||||
|
||||
public function testUnknownTypeHintOnlyListsTypesTheUserCanEdit(): void
|
||||
{
|
||||
Functions\when('get_post_type_object')->alias(static fn (string $type) => (object) [
|
||||
'cap' => (object) ['edit_posts' => $type === 'page' ? 'edit_pages' : 'edit_posts'],
|
||||
]);
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_posts');
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->andReturn(false);
|
||||
$schema->allows('describe')->andReturn(null);
|
||||
$schema->allows('postTypeNames')->andReturn(['page', 'post']);
|
||||
|
||||
$result = (new ContentTypesAbility($schema))->execute(['post_type' => 'nope']);
|
||||
|
||||
self::assertStringContainsString('post', $result->get_error_message());
|
||||
self::assertStringNotContainsString('Verfügbare Inhaltstypen: page', $result->get_error_message());
|
||||
}
|
||||
|
||||
public function testRequestingANonEditableSpecificTypeIsForbidden(): void
|
||||
{
|
||||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_pages']]);
|
||||
|
||||
@@ -90,6 +90,28 @@ final class PostPresenterTest extends TestCase
|
||||
self::assertStringContainsString('lit-divi-outline', $full['hints'][0]);
|
||||
self::assertInstanceOf(stdClass::class, $full['terms']);
|
||||
self::assertInstanceOf(stdClass::class, $full['fields']);
|
||||
self::assertSame(Rev::of('<!-- wp:divi/section /-->'), $full['rev']);
|
||||
}
|
||||
|
||||
public function testPasswordAndProtectedReadonlyMetaFieldsAreOmitted(): void
|
||||
{
|
||||
$this->stubLinks();
|
||||
Functions\when('get_post_meta')->justReturn('');
|
||||
Functions\when('get_post_thumbnail_id')->justReturn(0);
|
||||
$password = new FieldDefinition('secret', 'Secret', 'password', 'pods', 'writable');
|
||||
$protected = new FieldDefinition('_internal', 'Internal', 'string', 'meta', 'readonly');
|
||||
$visible = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable');
|
||||
$source = Mockery::mock(FieldSource::class);
|
||||
$source->expects('read')->once()->with(5, $visible)->andReturn('Leitung');
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('taxonomies')->andReturn([]);
|
||||
$schema->allows('fields')->andReturn(['secret' => $password, '_internal' => $protected, 'role' => $visible]);
|
||||
$schema->allows('sourceOf')->andReturn($source);
|
||||
$post = new WP_Post(['ID' => 5, 'post_type' => 'lit_person']);
|
||||
|
||||
$full = (new PostPresenter($schema))->full($post);
|
||||
|
||||
self::assertSame(['role' => 'Leitung'], $full['fields']);
|
||||
}
|
||||
|
||||
public function testDiviIsAlsoDetectedFromBlockMarkup(): void
|
||||
|
||||
Reference in New Issue
Block a user