Skip to content

Commit 82274ad

Browse files
Abilities API: Address content review follow-ups
1 parent bc6eebb commit 82274ad

2 files changed

Lines changed: 129 additions & 78 deletions

File tree

src/wp-includes/abilities/class-wp-content-abilities.php

Lines changed: 36 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ private function register_get_content(): void {
145145
$this->exposed_post_types = $this->get_exposed_post_types();
146146

147147
$post_types = array_keys( $this->exposed_post_types );
148-
$statuses = $this->get_available_statuses();
148+
$statuses = array_values( get_post_stati( array( 'internal' => false ) ) );
149149

150150
wp_register_ability(
151151
'core/content',
@@ -218,30 +218,12 @@ public function check_permission( $input = array() ): bool {
218218

219219
$post_type_object = $exposed[ $post_type ];
220220
if ( $requires_edit ) {
221-
$edit_posts_capability = $this->capability( $post_type_object, 'edit_posts', 'edit_posts' );
222-
223-
return current_user_can( $edit_posts_capability );
221+
return current_user_can( $post_type_object->cap->edit_posts );
224222
}
225223

226224
return $this->can_query_statuses( $input, $post_type_object );
227225
}
228226

229-
/**
230-
* Resolves a capability name from a post type's capability object, with a fallback.
231-
*
232-
* @since 7.1.0
233-
*
234-
* @param WP_Post_Type $post_type_object The post type object.
235-
* @param string $name Capability key on the post type's `cap` object.
236-
* @param string $fallback Fallback capability name if unset or non-string.
237-
* @return string The resolved capability name.
238-
*/
239-
private function capability( WP_Post_Type $post_type_object, string $name, string $fallback ): string {
240-
$capability = $post_type_object->cap->$name ?? $fallback;
241-
242-
return is_string( $capability ) ? $capability : $fallback;
243-
}
244-
245227
/**
246228
* Casts a raw input value to a non-negative integer.
247229
*
@@ -270,9 +252,9 @@ private function has_explicit_edit_fields( array $input ): bool {
270252
return false;
271253
}
272254

273-
$requested = array_filter( $input['fields'], 'is_string' );
255+
$requested_fields = array_filter( $input['fields'], 'is_string' );
274256

275-
return array() !== array_intersect( self::EDIT_FIELDS, $requested );
257+
return array() !== array_intersect( self::EDIT_FIELDS, $requested_fields );
276258
}
277259

278260
/**
@@ -289,19 +271,16 @@ private function has_explicit_edit_fields( array $input ): bool {
289271
* @return bool True if the requested statuses may be queried.
290272
*/
291273
private function can_query_statuses( array $input, WP_Post_Type $post_type_object ): bool {
292-
$edit_posts_capability = $this->capability( $post_type_object, 'edit_posts', 'edit_posts' );
293-
$read_private_capability = $this->capability( $post_type_object, 'read_private_posts', 'read_private_posts' );
294-
295274
foreach ( $this->normalize_statuses( $input ) as $status ) {
296275
if ( 'publish' === $status ) {
297276
continue;
298277
}
299278

300-
if ( 'private' === $status && current_user_can( $read_private_capability ) ) {
279+
if ( 'private' === $status && current_user_can( $post_type_object->cap->read_private_posts ) ) {
301280
continue;
302281
}
303282

304-
if ( current_user_can( $edit_posts_capability ) ) {
283+
if ( current_user_can( $post_type_object->cap->edit_posts ) ) {
305284
continue;
306285
}
307286

@@ -464,29 +443,13 @@ private function normalize_per_page( array $input ): int {
464443
* @return array<string, WP_Post_Type> Exposed post type objects keyed by name.
465444
*/
466445
private function get_exposed_post_types(): array {
467-
$exposed = array();
446+
$exposed_post_types = array();
468447

469-
foreach ( get_post_types( array(), 'objects' ) as $post_type_object ) {
470-
if ( empty( $post_type_object->show_in_abilities ) ) {
471-
continue;
472-
}
473-
$exposed[ $post_type_object->name ] = $post_type_object;
448+
foreach ( get_post_types( array( 'show_in_abilities' => true ), 'objects' ) as $post_type_object ) {
449+
$exposed_post_types[ $post_type_object->name ] = $post_type_object;
474450
}
475451

476-
return $exposed;
477-
}
478-
479-
/**
480-
* Returns the post statuses that may be requested through the ability.
481-
*
482-
* Internal statuses (auto-draft, inherit, trash) are excluded.
483-
*
484-
* @since 7.1.0
485-
*
486-
* @return string[] List of public, non-internal post status slugs.
487-
*/
488-
private function get_available_statuses(): array {
489-
return array_values( get_post_stati( array( 'internal' => false ) ) );
452+
return $exposed_post_types;
490453
}
491454

492455
/**
@@ -524,8 +487,8 @@ private function normalize_fields( array $input ): array {
524487
return $this->fields;
525488
}
526489

527-
$requested = array_filter( $input['fields'], 'is_string' );
528-
$fields = array_intersect( $this->fields, $requested );
490+
$requested_fields = array_filter( $input['fields'], 'is_string' );
491+
$fields = array_intersect( $this->fields, $requested_fields );
529492

530493
return array() === $fields ? $this->fields : array_values( $fields );
531494
}
@@ -776,84 +739,84 @@ private function get_content_output_schema(): array {
776739
* @return array<string, mixed> The formatted post data.
777740
*/
778741
private function format_post( WP_Post $post, array $fields ): array {
779-
$type = $post->post_type;
780-
$wants = static function ( string $field ) use ( $fields ): bool {
742+
$post_type = $post->post_type;
743+
$fields_requested = static function ( string $field ) use ( $fields ): bool {
781744
return in_array( $field, $fields, true );
782745
};
783-
$can_edit = current_user_can( 'edit_post', $post->ID );
784-
$protected = post_password_required( $post ) && ! $can_edit;
746+
$can_edit = current_user_can( 'edit_post', $post->ID );
747+
$protected = post_password_required( $post ) && ! $can_edit;
785748

786749
$data = array();
787750

788-
if ( $wants( 'id' ) ) {
751+
if ( $fields_requested( 'id' ) ) {
789752
$data['id'] = (int) $post->ID;
790753
}
791-
if ( $wants( 'type' ) ) {
792-
$data['type'] = $type;
754+
if ( $fields_requested( 'type' ) ) {
755+
$data['type'] = $post_type;
793756
}
794-
if ( $wants( 'status' ) ) {
757+
if ( $fields_requested( 'status' ) ) {
795758
$data['status'] = $post->post_status;
796759
}
797-
if ( $wants( 'date' ) ) {
760+
if ( $fields_requested( 'date' ) ) {
798761
$data['date'] = $this->format_local_date( $post, 'date' );
799762
}
800-
if ( $wants( 'date_gmt' ) ) {
763+
if ( $fields_requested( 'date_gmt' ) ) {
801764
$data['date_gmt'] = $this->format_gmt_date( $post, 'date' );
802765
}
803-
if ( $wants( 'modified' ) ) {
766+
if ( $fields_requested( 'modified' ) ) {
804767
$data['modified'] = $this->format_local_date( $post, 'modified' );
805768
}
806-
if ( $wants( 'modified_gmt' ) ) {
769+
if ( $fields_requested( 'modified_gmt' ) ) {
807770
$data['modified_gmt'] = $this->format_gmt_date( $post, 'modified' );
808771
}
809-
if ( $wants( 'slug' ) ) {
772+
if ( $fields_requested( 'slug' ) ) {
810773
$data['slug'] = $post->post_name;
811774
}
812-
if ( $wants( 'link' ) ) {
775+
if ( $fields_requested( 'link' ) ) {
813776
$data['link'] = (string) get_permalink( $post );
814777
}
815778

816-
if ( $wants( 'title_raw' ) && post_type_supports( $type, 'title' ) && $can_edit ) {
779+
if ( $fields_requested( 'title_raw' ) && post_type_supports( $post_type, 'title' ) && $can_edit ) {
817780
$data['title_raw'] = $post->post_title;
818781
}
819782

820-
if ( $wants( 'title_rendered' ) && post_type_supports( $type, 'title' ) ) {
783+
if ( $fields_requested( 'title_rendered' ) && post_type_supports( $post_type, 'title' ) ) {
821784
$data['title_rendered'] = $this->get_title( $post );
822785
}
823786

824-
if ( $wants( 'excerpt_raw' ) && post_type_supports( $type, 'excerpt' ) && $can_edit ) {
787+
if ( $fields_requested( 'excerpt_raw' ) && post_type_supports( $post_type, 'excerpt' ) && $can_edit ) {
825788
$data['excerpt_raw'] = $post->post_excerpt;
826789
}
827790

828-
if ( $wants( 'excerpt_rendered' ) && post_type_supports( $type, 'excerpt' ) ) {
791+
if ( $fields_requested( 'excerpt_rendered' ) && post_type_supports( $post_type, 'excerpt' ) ) {
829792
$data['excerpt_rendered'] = $protected ? '' : (string) get_the_excerpt( $post );
830793
}
831794

832-
if ( $wants( 'excerpt_protected' ) && post_type_supports( $type, 'excerpt' ) ) {
795+
if ( $fields_requested( 'excerpt_protected' ) && post_type_supports( $post_type, 'excerpt' ) ) {
833796
$data['excerpt_protected'] = (bool) $post->post_password;
834797
}
835798

836-
if ( $wants( 'content_raw' ) && post_type_supports( $type, 'editor' ) && $can_edit ) {
799+
if ( $fields_requested( 'content_raw' ) && post_type_supports( $post_type, 'editor' ) && $can_edit ) {
837800
$data['content_raw'] = $post->post_content;
838801
}
839802

840-
if ( $wants( 'content_rendered' ) && post_type_supports( $type, 'editor' ) ) {
803+
if ( $fields_requested( 'content_rendered' ) && post_type_supports( $post_type, 'editor' ) ) {
841804
$data['content_rendered'] = $protected ? '' : $this->get_rendered_content( $post );
842805
}
843806

844-
if ( $wants( 'content_protected' ) && post_type_supports( $type, 'editor' ) ) {
807+
if ( $fields_requested( 'content_protected' ) && post_type_supports( $post_type, 'editor' ) ) {
845808
$data['content_protected'] = (bool) $post->post_password;
846809
}
847810

848-
if ( $wants( 'author' ) && post_type_supports( $type, 'author' ) ) {
811+
if ( $fields_requested( 'author' ) && post_type_supports( $post_type, 'author' ) ) {
849812
$author = get_userdata( (int) $post->post_author );
850813
$data['author'] = array(
851814
'id' => (int) $post->post_author,
852815
'display_name' => $author ? $author->display_name : '',
853816
);
854817
}
855818

856-
if ( $wants( 'parent' ) && is_post_type_hierarchical( $type ) ) {
819+
if ( $fields_requested( 'parent' ) && is_post_type_hierarchical( $post_type ) ) {
857820
$data['parent'] = (int) $post->post_parent;
858821
}
859822

tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ private function login_as( string $role ): int {
9898
return $user_id;
9999
}
100100

101+
/**
102+
* Returns roles that can read public posts but cannot edit another user's post.
103+
*
104+
* @return array<string, array{role: string}> Role test cases.
105+
*/
106+
public function data_roles_without_edit_access_to_other_users_posts(): array {
107+
return array(
108+
'subscriber' => array(
109+
'role' => 'subscriber',
110+
),
111+
'contributor' => array(
112+
'role' => 'contributor',
113+
),
114+
'author' => array(
115+
'role' => 'author',
116+
),
117+
);
118+
}
119+
101120
/**
102121
* Convenience accessor for the ability.
103122
*
@@ -525,6 +544,66 @@ public function test_subscriber_cannot_request_raw_fields_for_single_post(): voi
525544
$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
526545
}
527546

547+
/**
548+
* Users who cannot edit another user's post do not receive raw fields by default.
549+
*
550+
* @dataProvider data_roles_without_edit_access_to_other_users_posts
551+
*
552+
* @param string $role The role to test.
553+
*/
554+
public function test_default_fields_omit_raw_fields_for_roles_without_edit_access_to_other_users_posts( string $role ): void {
555+
$post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
556+
$post_id = self::factory()->post->create(
557+
array(
558+
'post_author' => $post_owner_id,
559+
'post_title' => 'Readable title',
560+
'post_content' => 'Readable body for limited role.',
561+
'post_excerpt' => 'Readable excerpt.',
562+
'post_status' => 'publish',
563+
)
564+
);
565+
566+
$this->login_as( $role );
567+
568+
$result = $this->ability()->execute( array( 'id' => $post_id ) );
569+
570+
$this->assertIsArray( $result, 'The readable published post should be returned.' );
571+
$this->assertSame( 'Readable title', $result['posts'][0]['title_rendered'], 'Rendered title should remain visible.' );
572+
$this->assertStringContainsString( 'Readable body for limited role.', $result['posts'][0]['content_rendered'], 'Rendered content should remain visible.' );
573+
$this->assertArrayNotHasKey( 'title_raw', $result['posts'][0], 'Raw title should be omitted.' );
574+
$this->assertArrayNotHasKey( 'excerpt_raw', $result['posts'][0], 'Raw excerpt should be omitted.' );
575+
$this->assertArrayNotHasKey( 'content_raw', $result['posts'][0], 'Raw content should be omitted.' );
576+
}
577+
578+
/**
579+
* Users who cannot edit another user's post cannot explicitly request raw fields.
580+
*
581+
* @dataProvider data_roles_without_edit_access_to_other_users_posts
582+
*
583+
* @param string $role The role to test.
584+
*/
585+
public function test_raw_field_requests_are_denied_for_roles_without_edit_access_to_other_users_posts( string $role ): void {
586+
$post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
587+
$post_id = self::factory()->post->create(
588+
array(
589+
'post_author' => $post_owner_id,
590+
'post_status' => 'publish',
591+
)
592+
);
593+
594+
$this->login_as( $role );
595+
596+
$result = $this->ability()->execute(
597+
array(
598+
'id' => $post_id,
599+
'fields' => array( 'content_raw' ),
600+
)
601+
);
602+
603+
$this->assertWPError( $result );
604+
$this->assertSame( 'ability_invalid_permissions', $result->get_error_code(), 'Raw field requests should require edit access to the post.' );
605+
}
606+
528607
public function test_subscriber_cannot_request_draft_status(): void {
529608
$this->login_as( 'subscriber' );
530609

@@ -654,25 +733,34 @@ public function test_password_protected_content_visible_to_editor(): void {
654733
$this->assertStringContainsString( 'Top secret body.', $result['posts'][0]['content_rendered'] );
655734
}
656735

657-
public function test_password_protected_rendered_content_is_empty_for_subscriber(): void {
658-
$post_id = self::factory()->post->create(
736+
/**
737+
* Password-protected rendered content is withheld from users who cannot edit the post.
738+
*
739+
* @dataProvider data_roles_without_edit_access_to_other_users_posts
740+
*
741+
* @param string $role The role to test.
742+
*/
743+
public function test_password_protected_rendered_content_is_empty_for_roles_without_edit_access_to_other_users_posts( string $role ): void {
744+
$post_owner_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
745+
$post_id = self::factory()->post->create(
659746
array(
747+
'post_author' => $post_owner_id,
660748
'post_status' => 'publish',
661749
'post_password' => 'secret',
662750
'post_content' => 'Hidden rendered body.',
663751
)
664752
);
665753

666-
$this->login_as( 'subscriber' );
754+
$this->login_as( $role );
667755
$result = $this->ability()->execute(
668756
array(
669757
'id' => $post_id,
670758
'fields' => array( 'id', 'content_rendered', 'content_protected' ),
671759
)
672760
);
673761

674-
$this->assertSame( '', $result['posts'][0]['content_rendered'] );
675-
$this->assertTrue( $result['posts'][0]['content_protected'] );
762+
$this->assertSame( '', $result['posts'][0]['content_rendered'], 'Password-protected rendered content should be withheld.' );
763+
$this->assertTrue( $result['posts'][0]['content_protected'], 'The protected flag should reveal the field is password-protected.' );
676764
}
677765

678766
/*

0 commit comments

Comments
 (0)