From b8b41cb9e5a436e4e27e4769a0362b6d20ca58db Mon Sep 17 00:00:00 2001 From: Amaan Date: Tue, 25 Nov 2025 12:48:00 -0600 Subject: [PATCH 1/2] Add CRUD abilities for all post types Implements standardized Create, Read, Update, and Delete abilities for WordPress posts that work uniformly across all post types (built-in and custom). New abilities: - core/create-post: Create posts with title, content, excerpt, status - core/get-post: Retrieve a single post by ID - core/list-posts: List posts with pagination, filtering, and search - core/update-post: Partial updates to existing posts - core/delete-post: Soft delete (trash) or permanent deletion Features: - Respects WordPress capabilities system for all operations - Supports all post types via post_type parameter - Full input/output schema validation - Proper error handling with WP_Error and HTTP status codes - REST API exposure via show_in_rest annotation - Semantic annotations (readonly, destructive, idempotent) Closes #84 --- includes/abilities/wp-post-abilities.php | 686 ++++++++++++++++++ includes/bootstrap.php | 7 + .../abilities-api/wpRegisterPostAbilities.php | 440 +++++++++++ 3 files changed, 1133 insertions(+) create mode 100644 includes/abilities/wp-post-abilities.php create mode 100644 tests/unit/abilities-api/wpRegisterPostAbilities.php diff --git a/includes/abilities/wp-post-abilities.php b/includes/abilities/wp-post-abilities.php new file mode 100644 index 0000000..aafeb88 --- /dev/null +++ b/includes/abilities/wp-post-abilities.php @@ -0,0 +1,686 @@ + __( 'Content' ), + 'description' => __( 'Abilities for creating, reading, updating, and deleting posts of any type.' ), + ) + ); +} + +/** + * Registers the post CRUD abilities. + * + * @since 6.9.0 + * + * @return void + */ +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound +function wp_register_post_abilities(): void { + $category = 'content'; + + // ========================================================================= + // core/get-post - Read a single post by ID + // ========================================================================= + wp_register_ability( + 'core/get-post', + array( + 'label' => __( 'Get Post' ), + 'description' => __( 'Retrieves a single post by ID. Optionally validates the post type.' ), + 'category' => $category, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The post ID to retrieve.' ), + ), + 'post_type' => array( + 'type' => 'string', + 'description' => __( 'Optional: Validate that the post is of this type.' ), + ), + ), + 'required' => array( 'id' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The post ID.' ), + ), + 'title' => array( + 'type' => 'string', + 'description' => __( 'The post title.' ), + ), + 'content' => array( + 'type' => 'string', + 'description' => __( 'The post content.' ), + ), + 'excerpt' => array( + 'type' => 'string', + 'description' => __( 'The post excerpt.' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The post status.' ), + ), + 'post_type' => array( + 'type' => 'string', + 'description' => __( 'The post type.' ), + ), + 'date' => array( + 'type' => 'string', + 'description' => __( 'The post creation date.' ), + ), + 'modified' => array( + 'type' => 'string', + 'description' => __( 'The post modification date.' ), + ), + 'author' => array( + 'type' => 'integer', + 'description' => __( 'The post author ID.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'The post permalink.' ), + ), + ), + 'additionalProperties' => false, + ), + 'execute_callback' => static function ( $input = array() ) { + $post_id = absint( $input['id'] ); + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + __( 'Post not found.' ), + array( 'status' => 404 ) + ); + } + + // Validate post_type if specified. + if ( ! empty( $input['post_type'] ) && $post->post_type !== $input['post_type'] ) { + return new WP_Error( + 'invalid_post_type', + __( 'Post is not of the specified type.' ), + array( 'status' => 400 ) + ); + } + + return array( + 'id' => $post->ID, + 'title' => $post->post_title, + 'content' => $post->post_content, + 'excerpt' => $post->post_excerpt, + 'status' => $post->post_status, + 'post_type' => $post->post_type, + 'date' => $post->post_date, + 'modified' => $post->post_modified, + 'author' => (int) $post->post_author, + 'link' => get_permalink( $post->ID ), + ); + }, + 'permission_callback' => static function ( $input = array() ): bool { + if ( empty( $input['id'] ) ) { + return false; + } + $post = get_post( absint( $input['id'] ) ); + if ( ! $post ) { + return false; + } + return current_user_can( 'read_post', $post->ID ); + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + // ========================================================================= + // core/list-posts - List posts with filtering and pagination + // ========================================================================= + wp_register_ability( + 'core/list-posts', + array( + 'label' => __( 'List Posts' ), + 'description' => __( 'Retrieves a paginated list of posts with optional filtering by type, status, and search term.' ), + 'category' => $category, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'post_type' => array( + 'type' => 'string', + 'description' => __( 'The post type to query.' ), + 'default' => 'post', + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The post status to filter by.' ), + 'default' => 'publish', + ), + 'per_page' => array( + 'type' => 'integer', + 'description' => __( 'Number of posts per page.' ), + 'default' => 10, + 'minimum' => 1, + 'maximum' => 100, + ), + 'page' => array( + 'type' => 'integer', + 'description' => __( 'Page number for pagination.' ), + 'default' => 1, + 'minimum' => 1, + ), + 'search' => array( + 'type' => 'string', + 'description' => __( 'Search term to filter posts.' ), + ), + 'orderby' => array( + 'type' => 'string', + 'description' => __( 'Field to order by.' ), + 'default' => 'date', + 'enum' => array( 'date', 'title', 'modified', 'ID' ), + ), + 'order' => array( + 'type' => 'string', + 'description' => __( 'Order direction.' ), + 'default' => 'DESC', + 'enum' => array( 'ASC', 'DESC' ), + ), + ), + 'additionalProperties' => false, + 'default' => array(), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'posts' => array( + 'type' => 'array', + 'description' => __( 'Array of post objects.' ), + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'title' => array( 'type' => 'string' ), + 'excerpt' => array( 'type' => 'string' ), + 'status' => array( 'type' => 'string' ), + 'post_type' => array( 'type' => 'string' ), + 'date' => array( 'type' => 'string' ), + 'link' => array( 'type' => 'string' ), + ), + ), + ), + 'total' => array( + 'type' => 'integer', + 'description' => __( 'Total number of posts matching the query.' ), + ), + 'total_pages' => array( + 'type' => 'integer', + 'description' => __( 'Total number of pages.' ), + ), + ), + 'additionalProperties' => false, + ), + 'execute_callback' => static function ( $input = array() ): array { + $input = is_array( $input ) ? $input : array(); + + $post_type = sanitize_key( $input['post_type'] ?? 'post' ); + $status = sanitize_key( $input['status'] ?? 'publish' ); + $per_page = absint( $input['per_page'] ?? 10 ); + $page = absint( $input['page'] ?? 1 ); + $orderby = sanitize_key( $input['orderby'] ?? 'date' ); + $order = strtoupper( sanitize_key( $input['order'] ?? 'DESC' ) ); + + // Clamp per_page. + $per_page = max( 1, min( 100, $per_page ) ); + $page = max( 1, $page ); + + $query_args = array( + 'post_type' => $post_type, + 'post_status' => $status, + 'posts_per_page' => $per_page, + 'paged' => $page, + 'orderby' => $orderby, + 'order' => $order, + ); + + if ( ! empty( $input['search'] ) ) { + $query_args['s'] = sanitize_text_field( $input['search'] ); + } + + $query = new WP_Query( $query_args ); + $posts = array(); + + /** @var WP_Post $post */ + foreach ( $query->posts as $post ) { + $posts[] = array( + 'id' => $post->ID, + 'title' => $post->post_title, + 'excerpt' => $post->post_excerpt, + 'status' => $post->post_status, + 'post_type' => $post->post_type, + 'date' => $post->post_date, + 'link' => get_permalink( $post->ID ), + ); + } + + return array( + 'posts' => $posts, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + ); + }, + 'permission_callback' => static function ( $input = array() ): bool { + $post_type = sanitize_key( $input['post_type'] ?? 'post' ); + $post_type_obj = get_post_type_object( $post_type ); + + if ( ! $post_type_obj ) { + return false; + } + + return current_user_can( $post_type_obj->cap->read ); + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + // ========================================================================= + // core/create-post - Create a new post + // ========================================================================= + wp_register_ability( + 'core/create-post', + array( + 'label' => __( 'Create Post' ), + 'description' => __( 'Creates a new post of any type with the specified title and content.' ), + 'category' => $category, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'title' => array( + 'type' => 'string', + 'description' => __( 'The post title.' ), + ), + 'content' => array( + 'type' => 'string', + 'description' => __( 'The post content (can include block markup).' ), + 'default' => '', + ), + 'excerpt' => array( + 'type' => 'string', + 'description' => __( 'The post excerpt.' ), + 'default' => '', + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The post status.' ), + 'enum' => array( 'draft', 'publish', 'pending', 'private' ), + 'default' => 'draft', + ), + 'post_type' => array( + 'type' => 'string', + 'description' => __( 'The post type.' ), + 'default' => 'post', + ), + ), + 'required' => array( 'title' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The ID of the created post.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'The permalink to the post.' ), + ), + 'edit_link' => array( + 'type' => 'string', + 'description' => __( 'The URL to edit the post.' ), + ), + ), + 'required' => array( 'id' ), + 'additionalProperties' => false, + ), + 'execute_callback' => static function ( $input = array() ) { + $post_type = sanitize_key( $input['post_type'] ?? 'post' ); + + // Validate post type exists. + if ( ! post_type_exists( $post_type ) ) { + return new WP_Error( + 'invalid_post_type', + __( 'Invalid post type.' ), + array( 'status' => 400 ) + ); + } + + $post_data = array( + 'post_title' => sanitize_text_field( $input['title'] ), + 'post_content' => wp_kses_post( $input['content'] ?? '' ), + 'post_excerpt' => sanitize_textarea_field( $input['excerpt'] ?? '' ), + 'post_status' => sanitize_key( $input['status'] ?? 'draft' ), + 'post_type' => $post_type, + 'post_author' => get_current_user_id(), + ); + + $post_id = wp_insert_post( $post_data, true ); + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + return array( + 'id' => $post_id, + 'link' => get_permalink( $post_id ), + 'edit_link' => get_edit_post_link( $post_id, 'raw' ), + ); + }, + 'permission_callback' => static function ( $input = array() ): bool { + $post_type = sanitize_key( $input['post_type'] ?? 'post' ); + $post_type_obj = get_post_type_object( $post_type ); + + if ( ! $post_type_obj ) { + return false; + } + + // Check create capability. + if ( ! current_user_can( $post_type_obj->cap->create_posts ) ) { + return false; + } + + // Check publish capability if status is publish. + $status = sanitize_key( $input['status'] ?? 'draft' ); + return 'publish' !== $status || current_user_can( $post_type_obj->cap->publish_posts ); + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ), + 'show_in_rest' => true, + ), + ) + ); + + // ========================================================================= + // core/update-post - Update an existing post + // ========================================================================= + wp_register_ability( + 'core/update-post', + array( + 'label' => __( 'Update Post' ), + 'description' => __( 'Updates an existing post. Only provided fields will be modified.' ), + 'category' => $category, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The ID of the post to update.' ), + ), + 'title' => array( + 'type' => 'string', + 'description' => __( 'The new post title.' ), + ), + 'content' => array( + 'type' => 'string', + 'description' => __( 'The new post content.' ), + ), + 'excerpt' => array( + 'type' => 'string', + 'description' => __( 'The new post excerpt.' ), + ), + 'status' => array( + 'type' => 'string', + 'description' => __( 'The new post status.' ), + 'enum' => array( 'draft', 'publish', 'pending', 'private' ), + ), + ), + 'required' => array( 'id' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The ID of the updated post.' ), + ), + 'modified' => array( + 'type' => 'string', + 'description' => __( 'The modification timestamp.' ), + ), + 'link' => array( + 'type' => 'string', + 'description' => __( 'The permalink to the post.' ), + ), + 'edit_link' => array( + 'type' => 'string', + 'description' => __( 'The URL to edit the post.' ), + ), + ), + 'required' => array( 'id' ), + 'additionalProperties' => false, + ), + 'execute_callback' => static function ( $input = array() ) { + $post_id = absint( $input['id'] ); + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + __( 'Post not found.' ), + array( 'status' => 404 ) + ); + } + + // Build update array with only provided fields. + $post_data = array( 'ID' => $post_id ); + + if ( isset( $input['title'] ) ) { + $post_data['post_title'] = sanitize_text_field( $input['title'] ); + } + if ( isset( $input['content'] ) ) { + $post_data['post_content'] = wp_kses_post( $input['content'] ); + } + if ( isset( $input['excerpt'] ) ) { + $post_data['post_excerpt'] = sanitize_textarea_field( $input['excerpt'] ); + } + if ( isset( $input['status'] ) ) { + $post_data['post_status'] = sanitize_key( $input['status'] ); + } + + $result = wp_update_post( $post_data, true ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $updated_post = get_post( $post_id ); + + return array( + 'id' => $post_id, + 'modified' => $updated_post ? $updated_post->post_modified : '', + 'link' => get_permalink( $post_id ), + 'edit_link' => get_edit_post_link( $post_id, 'raw' ), + ); + }, + 'permission_callback' => static function ( $input = array() ): bool { + if ( empty( $input['id'] ) ) { + return false; + } + + $post_id = absint( $input['id'] ); + $post = get_post( $post_id ); + + if ( ! $post ) { + return false; + } + + // Check edit capability for this specific post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return false; + } + + // Check publish capability if changing to publish. + if ( isset( $input['status'] ) && 'publish' === $input['status'] && 'publish' !== $post->post_status ) { + $post_type_obj = get_post_type_object( $post->post_type ); + if ( ! $post_type_obj || ! current_user_can( $post_type_obj->cap->publish_posts ) ) { + return false; + } + } + + return true; + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + + // ========================================================================= + // core/delete-post - Delete a post (trash or permanent) + // ========================================================================= + wp_register_ability( + 'core/delete-post', + array( + 'label' => __( 'Delete Post' ), + 'description' => __( 'Moves a post to trash or permanently deletes it.' ), + 'category' => $category, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The ID of the post to delete.' ), + ), + 'force' => array( + 'type' => 'boolean', + 'description' => __( 'If true, permanently delete instead of moving to trash.' ), + 'default' => false, + ), + ), + 'required' => array( 'id' ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The ID of the deleted post.' ), + ), + 'deleted' => array( + 'type' => 'boolean', + 'description' => __( 'Whether the post was successfully deleted.' ), + ), + 'previous_status' => array( + 'type' => 'string', + 'description' => __( 'The status of the post before deletion.' ), + ), + ), + 'required' => array( 'id', 'deleted' ), + 'additionalProperties' => false, + ), + 'execute_callback' => static function ( $input = array() ) { + $post_id = absint( $input['id'] ); + $force = ! empty( $input['force'] ); + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + __( 'Post not found.' ), + array( 'status' => 404 ) + ); + } + + $previous_status = $post->post_status; + + // wp_delete_post returns the post object on success, false on failure. + $result = wp_delete_post( $post_id, $force ); + + if ( ! $result ) { + return new WP_Error( + 'delete_failed', + __( 'Failed to delete post.' ), + array( 'status' => 500 ) + ); + } + + return array( + 'id' => $post_id, + 'deleted' => true, + 'previous_status' => $previous_status, + ); + }, + 'permission_callback' => static function ( $input = array() ): bool { + if ( empty( $input['id'] ) ) { + return false; + } + + $post_id = absint( $input['id'] ); + $post = get_post( $post_id ); + + if ( ! $post ) { + return false; + } + + return current_user_can( 'delete_post', $post_id ); + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => true, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 19c29f1..bafac44 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -46,12 +46,19 @@ require_once __DIR__ . '/abilities/wp-core-abilities.php'; } +// Load post abilities registration functions. +if ( ! function_exists( 'wp_register_post_abilities' ) ) { + require_once __DIR__ . '/abilities/wp-post-abilities.php'; +} + // Register core abilities category and abilities when requested via filter or when not in test environment. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Plugin-specific hook for feature plugin context. if ( ! ( defined( 'WP_RUN_CORE_TESTS' ) || defined( 'WP_TESTS_CONFIG_FILE_PATH' ) || ( function_exists( 'getenv' ) && false !== getenv( 'WP_PHPUNIT__DIR' ) ) ) || apply_filters( 'abilities_api_register_core_abilities', false ) ) { if ( function_exists( 'add_action' ) ) { add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + add_action( 'wp_abilities_api_categories_init', 'wp_register_post_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_post_abilities' ); } } diff --git a/tests/unit/abilities-api/wpRegisterPostAbilities.php b/tests/unit/abilities-api/wpRegisterPostAbilities.php new file mode 100644 index 0000000..2e09175 --- /dev/null +++ b/tests/unit/abilities-api/wpRegisterPostAbilities.php @@ -0,0 +1,440 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$editor_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + self::$subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + // Create a test post. + self::$test_post_id = self::factory()->post->create( + array( + 'post_title' => 'Test Post', + 'post_content' => 'Test content.', + 'post_status' => 'publish', + 'post_author' => self::$admin_id, + ) + ); + + // Ensure post abilities are registered for these tests. + remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + add_action( 'wp_abilities_api_categories_init', 'wp_register_post_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_post_abilities' ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * Tear down after the class. + * + * @since 6.9.0 + */ + public static function tear_down_after_class(): void { + // Re-add the unhook functions for subsequent tests. + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + // Remove the post abilities and their categories. + $abilities_to_remove = array( + 'core/get-post', + 'core/list-posts', + 'core/create-post', + 'core/update-post', + 'core/delete-post', + ); + foreach ( $abilities_to_remove as $ability_name ) { + wp_unregister_ability( $ability_name ); + } + wp_unregister_ability_category( 'content' ); + + parent::tear_down_after_class(); + } + + /** + * Reset current user after each test. + */ + public function tear_down(): void { + wp_set_current_user( 0 ); + parent::tear_down(); + } + + // ========================================================================= + // Category Tests + // ========================================================================= + + /** + * Tests that the content category is registered. + */ + public function test_content_category_is_registered(): void { + $category = wp_get_ability_category( 'content' ); + + $this->assertInstanceOf( WP_Ability_Category::class, $category ); + $this->assertSame( 'Content', $category->get_label() ); + } + + // ========================================================================= + // core/get-post Tests + // ========================================================================= + + /** + * Tests that the core/get-post ability is registered with the expected schema. + */ + public function test_core_get_post_ability_is_registered(): void { + $ability = wp_get_ability( 'core/get-post' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $input_schema = $ability->get_input_schema(); + $output_schema = $ability->get_output_schema(); + + $this->assertSame( 'object', $input_schema['type'] ); + $this->assertArrayHasKey( 'id', $input_schema['properties'] ); + $this->assertContains( 'id', $input_schema['required'] ); + + $this->assertArrayHasKey( 'id', $output_schema['properties'] ); + $this->assertArrayHasKey( 'title', $output_schema['properties'] ); + $this->assertArrayHasKey( 'content', $output_schema['properties'] ); + } + + /** + * Tests that core/get-post returns post data for an existing post. + */ + public function test_core_get_post_returns_post_data(): void { + wp_set_current_user( self::$admin_id ); + + $ability = wp_get_ability( 'core/get-post' ); + $result = $ability->execute( array( 'id' => self::$test_post_id ) ); + + $this->assertIsArray( $result ); + $this->assertSame( self::$test_post_id, $result['id'] ); + $this->assertSame( 'Test Post', $result['title'] ); + $this->assertSame( 'Test content.', $result['content'] ); + $this->assertSame( 'publish', $result['status'] ); + $this->assertSame( 'post', $result['post_type'] ); + } + + /** + * Tests that core/get-post returns an error for a non-existent post. + * Permission callback runs first and fails because post doesn't exist. + */ + public function test_core_get_post_returns_error_for_nonexistent(): void { + wp_set_current_user( self::$admin_id ); + + $ability = wp_get_ability( 'core/get-post' ); + $result = $ability->execute( array( 'id' => 999999 ) ); + + $this->assertWPError( $result ); + // Permission callback fails first because post doesn't exist. + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + /** + * Tests that core/get-post respects permissions. + */ + public function test_core_get_post_respects_permissions(): void { + // Create a private post. + $private_post_id = self::factory()->post->create( + array( + 'post_status' => 'private', + 'post_author' => self::$admin_id, + ) + ); + + // Subscriber should not be able to read private post. + wp_set_current_user( self::$subscriber_id ); + + $ability = wp_get_ability( 'core/get-post' ); + $this->assertFalse( $ability->check_permissions( array( 'id' => $private_post_id ) ) ); + + // Admin should be able to read private post. + wp_set_current_user( self::$admin_id ); + $this->assertTrue( $ability->check_permissions( array( 'id' => $private_post_id ) ) ); + + wp_delete_post( $private_post_id, true ); + } + + // ========================================================================= + // core/list-posts Tests + // ========================================================================= + + /** + * Tests that the core/list-posts ability is registered. + */ + public function test_core_list_posts_ability_is_registered(): void { + $ability = wp_get_ability( 'core/list-posts' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $output_schema = $ability->get_output_schema(); + $this->assertArrayHasKey( 'posts', $output_schema['properties'] ); + $this->assertArrayHasKey( 'total', $output_schema['properties'] ); + $this->assertArrayHasKey( 'total_pages', $output_schema['properties'] ); + } + + /** + * Tests that core/list-posts returns paginated results. + */ + public function test_core_list_posts_returns_paginated_results(): void { + wp_set_current_user( self::$admin_id ); + + // Create additional posts. + $post_ids = self::factory()->post->create_many( 5 ); + + $ability = wp_get_ability( 'core/list-posts' ); + $result = $ability->execute( + array( + 'per_page' => 3, + 'page' => 1, + ) + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'posts', $result ); + $this->assertArrayHasKey( 'total', $result ); + $this->assertArrayHasKey( 'total_pages', $result ); + $this->assertCount( 3, $result['posts'] ); + $this->assertGreaterThanOrEqual( 6, $result['total'] ); // At least 6 posts (1 from setup + 5 created). + + // Cleanup. + foreach ( $post_ids as $post_id ) { + wp_delete_post( $post_id, true ); + } + } + + /** + * Tests that core/list-posts filters by post type. + */ + public function test_core_list_posts_filters_by_post_type(): void { + wp_set_current_user( self::$admin_id ); + + // Create a page. + $page_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + + $ability = wp_get_ability( 'core/list-posts' ); + + // Query for pages only. + $result = $ability->execute( array( 'post_type' => 'page' ) ); + + $this->assertIsArray( $result ); + foreach ( $result['posts'] as $post ) { + $this->assertSame( 'page', $post['post_type'] ); + } + + wp_delete_post( $page_id, true ); + } + + // ========================================================================= + // core/create-post Tests + // ========================================================================= + + /** + * Tests that the core/create-post ability is registered. + */ + public function test_core_create_post_ability_is_registered(): void { + $ability = wp_get_ability( 'core/create-post' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $input_schema = $ability->get_input_schema(); + $this->assertContains( 'title', $input_schema['required'] ); + } + + /** + * Tests that core/create-post creates a draft by default. + */ + public function test_core_create_post_creates_draft(): void { + wp_set_current_user( self::$editor_id ); + + $ability = wp_get_ability( 'core/create-post' ); + $result = $ability->execute( + array( + 'title' => 'New Test Post', + 'content' => 'New test content.', + ) + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'id', $result ); + $this->assertArrayHasKey( 'link', $result ); + + $post = get_post( $result['id'] ); + $this->assertSame( 'New Test Post', $post->post_title ); + $this->assertSame( 'draft', $post->post_status ); + + wp_delete_post( $result['id'], true ); + } + + /** + * Tests that core/create-post respects permissions. + */ + public function test_core_create_post_respects_permissions(): void { + // Subscriber should not be able to create posts. + wp_set_current_user( self::$subscriber_id ); + + $ability = wp_get_ability( 'core/create-post' ); + $this->assertFalse( $ability->check_permissions( array( 'title' => 'Test' ) ) ); + + // Editor should be able to create posts. + wp_set_current_user( self::$editor_id ); + $this->assertTrue( $ability->check_permissions( array( 'title' => 'Test' ) ) ); + } + + // ========================================================================= + // core/update-post Tests + // ========================================================================= + + /** + * Tests that core/update-post updates fields. + */ + public function test_core_update_post_updates_fields(): void { + wp_set_current_user( self::$admin_id ); + + // Create a post to update. + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Original Title', + 'post_status' => 'draft', + ) + ); + + $ability = wp_get_ability( 'core/update-post' ); + $result = $ability->execute( + array( + 'id' => $post_id, + 'title' => 'Updated Title', + ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( $post_id, $result['id'] ); + $this->assertArrayHasKey( 'modified', $result ); + + $updated_post = get_post( $post_id ); + $this->assertSame( 'Updated Title', $updated_post->post_title ); + + wp_delete_post( $post_id, true ); + } + + /** + * Tests that core/update-post returns error for non-existent post. + * Permission callback runs first and fails because post doesn't exist. + */ + public function test_core_update_post_returns_error_for_nonexistent(): void { + wp_set_current_user( self::$admin_id ); + + $ability = wp_get_ability( 'core/update-post' ); + $result = $ability->execute( + array( + 'id' => 999999, + 'title' => 'Updated', + ) + ); + + $this->assertWPError( $result ); + // Permission callback fails first because post doesn't exist. + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + // ========================================================================= + // core/delete-post Tests + // ========================================================================= + + /** + * Tests that core/delete-post moves post to trash by default. + */ + public function test_core_delete_post_moves_to_trash(): void { + wp_set_current_user( self::$admin_id ); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $ability = wp_get_ability( 'core/delete-post' ); + $result = $ability->execute( array( 'id' => $post_id ) ); + + $this->assertIsArray( $result ); + $this->assertSame( $post_id, $result['id'] ); + $this->assertTrue( $result['deleted'] ); + $this->assertSame( 'publish', $result['previous_status'] ); + + // Post should be in trash. + $post = get_post( $post_id ); + $this->assertSame( 'trash', $post->post_status ); + + wp_delete_post( $post_id, true ); + } + + /** + * Tests that core/delete-post with force permanently deletes. + */ + public function test_core_delete_post_force_deletes_permanently(): void { + wp_set_current_user( self::$admin_id ); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $ability = wp_get_ability( 'core/delete-post' ); + $result = $ability->execute( + array( + 'id' => $post_id, + 'force' => true, + ) + ); + + $this->assertIsArray( $result ); + $this->assertTrue( $result['deleted'] ); + + // Post should be completely gone. + $post = get_post( $post_id ); + $this->assertNull( $post ); + } +} From c74389d4922c14a1dfab3783250951df099c391f Mon Sep 17 00:00:00 2001 From: Amaan Date: Tue, 25 Nov 2025 12:57:13 -0600 Subject: [PATCH 2/2] Fix PHPCS warnings for dynamic capability checks Add phpcs:ignore comments for WordPress.WP.Capabilities.Undetermined warnings. These are false positives - the capabilities are dynamically retrieved from post type objects which is a valid WordPress pattern. --- includes/abilities/wp-post-abilities.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/includes/abilities/wp-post-abilities.php b/includes/abilities/wp-post-abilities.php index aafeb88..16f2e66 100644 --- a/includes/abilities/wp-post-abilities.php +++ b/includes/abilities/wp-post-abilities.php @@ -308,6 +308,7 @@ function wp_register_post_abilities(): void { return false; } + // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Dynamic capability from post type object. return current_user_can( $post_type_obj->cap->read ); }, 'meta' => array( @@ -423,12 +424,14 @@ function wp_register_post_abilities(): void { } // Check create capability. + // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Dynamic capability from post type object. if ( ! current_user_can( $post_type_obj->cap->create_posts ) ) { return false; } // Check publish capability if status is publish. $status = sanitize_key( $input['status'] ?? 'draft' ); + // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Dynamic capability from post type object. return 'publish' !== $status || current_user_can( $post_type_obj->cap->publish_posts ); }, 'meta' => array( @@ -565,6 +568,7 @@ function wp_register_post_abilities(): void { // Check publish capability if changing to publish. if ( isset( $input['status'] ) && 'publish' === $input['status'] && 'publish' !== $post->post_status ) { $post_type_obj = get_post_type_object( $post->post_type ); + // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Dynamic capability from post type object. if ( ! $post_type_obj || ! current_user_can( $post_type_obj->cap->publish_posts ) ) { return false; }