diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index e31ce5ba..84181db5 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -320,6 +320,27 @@ public function get_input_schema(): array { return $this->input_schema; } + /** + * Applies the defined input default when no input is provided. + * + * @since 0.4.0 + * + * @param mixed $input Optional. The raw input provided for the ability. Default `null`. + * @return mixed The input with the schema default applied when available. + */ + public function normalize_input( $input = null ) { + if ( null !== $input ) { + return $input; + } + + $input_schema = $this->get_input_schema(); + if ( ! empty( $input_schema ) && array_key_exists( 'default', $input_schema ) ) { + return $input_schema['default']; + } + + return null; + } + /** * Retrieves the output schema for the ability. * @@ -436,6 +457,7 @@ protected function invoke_callback( callable $callback, $input = null ) { * @return bool|\WP_Error Whether the ability has the necessary permission. */ public function check_permissions( $input = null ) { + $input = $this->normalize_input( $input ); $is_valid = $this->validate_input( $input ); if ( is_wp_error( $is_valid ) ) { return $is_valid; @@ -522,6 +544,7 @@ protected function validate_output( $output ) { * @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure. */ public function execute( $input = null ) { + $input = $this->normalize_input( $input ); $has_permissions = $this->check_permissions( $input ); if ( true !== $has_permissions ) { if ( is_wp_error( $has_permissions ) ) { diff --git a/includes/abilities/class-wp-core-abilities.php b/includes/abilities/class-wp-core-abilities.php new file mode 100644 index 00000000..66635acd --- /dev/null +++ b/includes/abilities/class-wp-core-abilities.php @@ -0,0 +1,317 @@ + __( 'Site' ), + 'description' => __( 'Abilities that retrieve or modify site information and settings.' ), + ) + ); + + // User-related capabilities + wp_register_ability_category( + self::CATEGORY_USER, + array( + 'label' => __( 'User' ), + 'description' => __( 'Abilities that retrieve or modify user information and settings.' ), + ) + ); + } + + /** + * Registers the default core abilities. + * + * @since 0.3.0 + * + * @return void + */ + public static function register(): void { + self::register_get_site_info(); + self::register_get_user_info(); + self::register_get_environment_info(); + } + + /** + * Registers the `core/get-site-info` ability. + * + * @since 0.3.0 + * + * @return void + */ + protected static function register_get_site_info(): void { + $fields = array( + 'name', + 'description', + 'url', + 'wpurl', + 'admin_email', + 'charset', + 'language', + 'version', + ); + + wp_register_ability( + 'core/get-site-info', + array( + 'label' => __( 'Get Site Information' ), + 'description' => __( 'Returns site information configured in WordPress. By default returns all fields, or optionally a filtered subset.' ), + 'category' => self::CATEGORY_SITE, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'fields' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => $fields, + ), + 'description' => __( 'Optional: Limit response to specific fields. If omitted, all fields are returned.' ), + ), + ), + 'additionalProperties' => false, + 'default' => array(), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => __( 'The site title.' ), + ), + 'description' => array( + 'type' => 'string', + 'description' => __( 'The site tagline.' ), + ), + 'url' => array( + 'type' => 'string', + 'description' => __( 'The site home URL.' ), + ), + 'wpurl' => array( + 'type' => 'string', + 'description' => __( 'The WordPress installation URL.' ), + ), + 'admin_email' => array( + 'type' => 'string', + 'description' => __( 'The site administrator email address.' ), + ), + 'charset' => array( + 'type' => 'string', + 'description' => __( 'The site character encoding.' ), + ), + 'language' => array( + 'type' => 'string', + 'description' => __( 'The site language locale code.' ), + ), + 'version' => array( + 'type' => 'string', + 'description' => __( 'The WordPress version.' ), + ), + ), + 'additionalProperties' => false, + ), + 'execute_callback' => static function ( $input = array() ): array { + $input = is_array( $input ) ? $input : array(); + $all_fields = array( 'name', 'description', 'url', 'wpurl', 'admin_email', 'charset', 'language', 'version' ); + $requested_fields = ! empty( $input['fields'] ) ? $input['fields'] : $all_fields; + + $result = array(); + foreach ( $requested_fields as $field ) { + $result[ $field ] = get_bloginfo( $field ); + } + + return $result; + }, + 'permission_callback' => static function (): bool { + return current_user_can( 'manage_options' ); + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Registers the `core/get-user-info` ability. + * + * @since 0.3.0 + * + * @return void + */ + protected static function register_get_user_info(): void { + wp_register_ability( + 'core/get-user-info', + array( + 'label' => __( 'Get User Information' ), + 'description' => __( 'Returns basic profile details for the current authenticated user to support personalization, auditing, and access-aware behavior.' ), + 'category' => self::CATEGORY_USER, + 'output_schema' => array( + 'type' => 'object', + 'required' => array( 'id', 'display_name', 'user_nicename', 'user_login', 'roles', 'locale' ), + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => __( 'The user ID.' ), + ), + 'display_name' => array( + 'type' => 'string', + 'description' => __( 'The display name of the user.' ), + ), + 'user_nicename' => array( + 'type' => 'string', + 'description' => __( 'The URL-friendly name for the user.' ), + ), + 'user_login' => array( + 'type' => 'string', + 'description' => __( 'The login username for the user.' ), + ), + 'roles' => array( + 'type' => 'array', + 'description' => __( 'The roles assigned to the user.' ), + 'items' => array( + 'type' => 'string', + ), + ), + 'locale' => array( + 'type' => 'string', + 'description' => __( 'The locale string for the user, such as en_US.' ), + ), + ), + 'additionalProperties' => false, + ), + 'execute_callback' => static function (): array { + $current_user = wp_get_current_user(); + + return array( + 'id' => $current_user->ID, + 'display_name' => $current_user->display_name, + 'user_nicename' => $current_user->user_nicename, + 'user_login' => $current_user->user_login, + 'roles' => $current_user->roles, + 'locale' => get_user_locale( $current_user ), + ); + }, + 'permission_callback' => static function (): bool { + return is_user_logged_in(); + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => false, + ), + ) + ); + } + + /** + * Registers the `core/get-environment-info` ability. + * + * @since 0.3.0 + * + * @return void + */ + protected static function register_get_environment_info(): void { + wp_register_ability( + 'core/get-environment-info', + array( + 'label' => __( 'Get Environment Info' ), + 'description' => __( 'Returns core details about the site\'s runtime context for diagnostics and compatibility (environment, PHP runtime, database server info, WordPress version).' ), + 'category' => self::CATEGORY_SITE, + 'output_schema' => array( + 'type' => 'object', + 'required' => array( 'environment', 'php_version', 'db_server_info', 'wp_version' ), + 'properties' => array( + 'environment' => array( + 'type' => 'string', + 'description' => __( 'The site\'s runtime environment classification (e.g., production, staging, development).' ), + 'examples' => array( 'production', 'staging', 'development', 'local' ), + ), + 'php_version' => array( + 'type' => 'string', + 'description' => __( 'The PHP runtime version executing WordPress.' ), + ), + 'db_server_info' => array( + 'type' => 'string', + 'description' => __( 'The database server vendor and version string reported by the driver.' ), + 'examples' => array( '8.0.34', '10.11.6-MariaDB' ), + ), + 'wp_version' => array( + 'type' => 'string', + 'description' => __( 'The WordPress core version running on this site.' ), + ), + ), + 'additionalProperties' => false, + ), + 'execute_callback' => static function (): array { + global $wpdb; + + $env = wp_get_environment_type(); + $php_version = phpversion(); + $db_server_info = ''; + if ( isset( $wpdb ) && is_object( $wpdb ) && method_exists( $wpdb, 'db_server_info' ) ) { + $db_server_info = $wpdb->db_server_info() ?? ''; + } + $wp_version = get_bloginfo( 'version' ); + + return array( + 'environment' => $env, + 'php_version' => $php_version, + 'db_server_info' => $db_server_info, + 'wp_version' => $wp_version, + ); + }, + 'permission_callback' => static function (): bool { + return current_user_can( 'manage_options' ); + }, + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php index ae23de56..ecafd7a2 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -41,6 +41,20 @@ require_once __DIR__ . '/abilities-api.php'; } +// Load core abilities class. +if ( ! class_exists( 'WP_Core_Abilities' ) ) { + require_once __DIR__ . '/abilities/class-wp-core-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( 'abilities_api_categories_init', array( 'WP_Core_Abilities', 'register_category' ) ); + add_action( 'abilities_api_init', array( 'WP_Core_Abilities', 'register' ) ); + } +} + // Load REST API init class for plugin bootstrap. if ( ! class_exists( 'WP_REST_Abilities_Init' ) ) { require_once __DIR__ . '/rest-api/class-wp-rest-abilities-init.php'; diff --git a/includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php b/includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php index 5fdbfea4..a1c663a0 100644 --- a/includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php +++ b/includes/rest-api/endpoints/class-wp-rest-abilities-run-controller.php @@ -132,7 +132,7 @@ public function run_ability( $request ) { ); } - $input = $this->get_input_from_request( $request ); + $input = $ability->normalize_input( $this->get_input_from_request( $request ) ); $result = $ability->execute( $input ); if ( is_wp_error( $result ) ) { if ( 'ability_invalid_input' === $result->get_error_code() ) { @@ -162,7 +162,7 @@ public function run_ability_permissions_check( $request ) { ); } - $input = $this->get_input_from_request( $request ); + $input = $ability->normalize_input( $this->get_input_from_request( $request ) ); if ( ! $ability->check_permissions( $input ) ) { return new \WP_Error( 'rest_ability_cannot_execute', diff --git a/tests/unit/abilities-api/wpCoreAbilities.php b/tests/unit/abilities-api/wpCoreAbilities.php new file mode 100644 index 00000000..16203a77 --- /dev/null +++ b/tests/unit/abilities-api/wpCoreAbilities.php @@ -0,0 +1,180 @@ +is_registered( 'core/get-site-info' ) ) { + $registry->unregister( 'core/get-site-info' ); + } + if ( $registry->is_registered( 'core/get-user-info' ) ) { + $registry->unregister( 'core/get-user-info' ); + } + if ( $registry->is_registered( 'core/get-environment-info' ) ) { + $registry->unregister( 'core/get-environment-info' ); + } + + // Unregister categories if they exist. + $category_registry = WP_Abilities_Category_Registry::get_instance(); + if ( $category_registry->is_registered( 'site' ) ) { + wp_unregister_ability_category( 'site' ); + } + if ( $category_registry->is_registered( 'user' ) ) { + wp_unregister_ability_category( 'user' ); + } + + // Register core abilities category during the proper hook. + $callback = static function (): void { + WP_Core_Abilities::register_category(); + }; + add_action( 'abilities_api_categories_init', $callback ); + do_action( 'abilities_api_categories_init', WP_Abilities_Category_Registry::get_instance() ); + remove_action( 'abilities_api_categories_init', $callback ); + + // Fire the init action if it hasn't been fired yet. + if ( ! did_action( 'abilities_api_init' ) ) { + do_action( 'abilities_api_init' ); + } + + // Register core abilities for testing. + WP_Core_Abilities::register(); + } + + /** + * Tests that the `core/get-site-info` ability is registered with the expected schema. + */ + public function test_core_get_bloginfo_ability_is_registered(): void { + $ability = wp_get_ability( 'core/get-site-info' ); + + $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( 'default', $input_schema ); + $this->assertSame( array(), $input_schema['default'] ); + + // Input schema should have optional fields array. + $this->assertArrayHasKey( 'fields', $input_schema['properties'] ); + $this->assertSame( 'array', $input_schema['properties']['fields']['type'] ); + $this->assertContains( 'name', $input_schema['properties']['fields']['items']['enum'] ); + + // Output schema should have all fields documented. + $this->assertArrayHasKey( 'name', $output_schema['properties'] ); + $this->assertArrayHasKey( 'url', $output_schema['properties'] ); + $this->assertArrayHasKey( 'version', $output_schema['properties'] ); + } + + /** + * Tests executing the `core/get-site-info` ability returns all fields by default. + */ + public function test_core_get_bloginfo_executes(): void { + // Requires manage_options. + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $ability = wp_get_ability( 'core/get-site-info' ); + + // Test without fields parameter - should return all fields. + $result = $ability->execute(); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'name', $result ); + $this->assertArrayHasKey( 'description', $result ); + $this->assertArrayHasKey( 'url', $result ); + $this->assertArrayHasKey( 'version', $result ); + $this->assertSame( get_bloginfo( 'name' ), $result['name'] ); + + // Test with fields parameter - should return only requested fields. + $result = $ability->execute( + array( + 'fields' => array( 'name', 'url' ), + ) + ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'name', $result ); + $this->assertArrayHasKey( 'url', $result ); + $this->assertArrayNotHasKey( 'description', $result ); + $this->assertArrayNotHasKey( 'version', $result ); + $this->assertSame( get_bloginfo( 'name' ), $result['name'] ); + $this->assertSame( get_bloginfo( 'url' ), $result['url'] ); + + wp_set_current_user( 0 ); + } + + /** + * Tests that executing the current user info ability requires authentication. + */ + public function test_core_get_current_user_info_requires_authentication(): void { + $ability = wp_get_ability( 'core/get-user-info' ); + + $this->assertFalse( $ability->check_permissions() ); + + $result = $ability->execute(); + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + /** + * Tests executing the current user info ability as an authenticated user. + */ + public function test_core_get_current_user_info_returns_user_data(): void { + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'locale' => 'fr_FR', + ) + ); + + wp_set_current_user( $user_id ); + + $ability = wp_get_ability( 'core/get-user-info' ); + + $this->assertTrue( $ability->check_permissions() ); + + $result = $ability->execute(); + $this->assertSame( $user_id, $result['id'] ); + $this->assertSame( 'fr_FR', $result['locale'] ); + $this->assertSame( 'subscriber', $result['roles'][0] ); + $this->assertSame( get_userdata( $user_id )->display_name, $result['display_name'] ); + + wp_set_current_user( 0 ); + } + + /** + * Tests executing the environment info ability. + */ + public function test_core_get_environment_type_executes(): void { + // Requires manage_options. + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $ability = wp_get_ability( 'core/get-environment-info' ); + $environment = wp_get_environment_type(); + $ability_data = $ability->execute(); + + $this->assertIsArray( $ability_data ); + $this->assertArrayHasKey( 'environment', $ability_data ); + $this->assertArrayHasKey( 'php_version', $ability_data ); + $this->assertArrayHasKey( 'db_server_info', $ability_data ); + $this->assertArrayHasKey( 'wp_version', $ability_data ); + $this->assertSame( $environment, $ability_data['environment'] ); + + wp_set_current_user( 0 ); + } + +}