diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 1386c0deb7741..7057939b9026b 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -9,6 +9,8 @@ declare( strict_types = 1 ); +require_once __DIR__ . '/abilities/class-wp-abilities-settings.php'; + /** * Registers the core ability categories. * @@ -360,4 +362,7 @@ function wp_register_core_abilities(): void { ), ) ); + + // Register the settings abilities. + ( new WP_Abilities_Settings() )->register(); } diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php new file mode 100644 index 0000000000000..2ebeb303ef9cb --- /dev/null +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -0,0 +1,270 @@ +}> + */ + private $exposed_settings = array(); + + /** + * Registers all settings abilities. + * + * Must run on the `wp_abilities_api_init` hook. Registers nothing when no setting is + * exposed to abilities. + * + * @since 7.2.0 + */ + public function register(): void { + // Compute once; execute_get_settings() reuses this exact structure. + $this->exposed_settings = $this->get_exposed_settings(); + if ( empty( $this->exposed_settings ) ) { + return; + } + + $this->register_get_settings(); + } + + /** + * Registers the read-only `core/settings-get` ability. + * + * @since 7.2.0 + */ + private function register_get_settings(): void { + $groups = array_values( array_unique( array_filter( array_column( $this->exposed_settings, 'group' ) ) ) ); + + wp_register_ability( + 'core/settings-get', + array( + 'label' => __( 'Settings Get' ), + 'description' => __( 'Returns WordPress settings as a flat map of setting name to value. By default returns all settings exposed to abilities, or optionally a subset filtered by settings group, by setting name, or both. A setting whose value does not match its schema is left out.' ), + 'category' => 'site', + 'input_schema' => $this->get_settings_input_schema( $groups, array_keys( $this->exposed_settings ) ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'A map of setting name to its current value.' ), + 'properties' => wp_list_pluck( $this->exposed_settings, 'schema' ), + 'additionalProperties' => false, + ), + 'execute_callback' => array( $this, 'execute_get_settings' ), + 'permission_callback' => array( $this, 'has_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Executes the `core/settings-get` ability. + * + * @since 7.2.0 + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array Map of exposed setting name to current value. + */ + public function execute_get_settings( $input = array() ): array { + $input = rest_sanitize_object( $input ); + $group = isset( $input['group'] ) && is_string( $input['group'] ) ? $input['group'] : ''; + $fields = isset( $input['fields'] ) && is_array( $input['fields'] ) ? $input['fields'] : array(); + + $result = array(); + foreach ( $this->exposed_settings as $exposed_name => $setting ) { + if ( '' !== $group && $setting['group'] !== $group ) { + continue; + } + if ( ! empty( $fields ) && ! in_array( $exposed_name, $fields, true ) ) { + continue; + } + + $value = get_option( $setting['option'] ); + + // WordPress stores false as '', which the boolean schema rejects, while the settings endpoint answers null for it. + if ( '' === $value && 'boolean' === $setting['schema']['type'] ) { + $value = false; + } + + /* + * As the settings endpoint does, validate the stored value before sanitizing it, and + * leave out a value its schema rejects instead of failing output validation for every + * setting; the settings endpoint answers null for it. + */ + if ( is_wp_error( rest_validate_value_from_schema( $value, $setting['schema'] ) ) ) { + continue; + } + + $value = rest_sanitize_value_from_schema( $value, $setting['schema'] ); + + // Object (not array()) so an empty object value is serialized as {}, consistent with type:object. + $result[ $exposed_name ] = 'object' === $setting['schema']['type'] ? (object) $value : $value; + } + + return $result; + } + + /** + * Checks whether the current user may use the settings abilities. + * + * @since 7.2.0 + * + * @return bool True if the current user can manage options. + */ + public function has_permission(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * Builds the input schema for the get ability: optional filters by group and/or name. + * + * Both `group` and `fields` are optional; supplying both narrows the response to their + * intersection, and supplying neither returns every exposed setting. + * + * @since 7.2.0 + * + * @param list $groups Available settings groups. + * @param list $field_names Available exposed setting names. + * @return array The input JSON Schema. + */ + private function get_settings_input_schema( array $groups, array $field_names ): array { + return array( + 'type' => 'object', + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), + 'properties' => array( + 'group' => array( + 'type' => 'string', + 'enum' => $groups, + 'description' => __( 'Return only settings that belong to this settings group.' ), + ), + 'fields' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => $field_names, + ), + 'description' => __( 'Return only the settings with these names.' ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Returns the settings exposed through the Abilities API. + * + * Reads {@see get_registered_settings()} and keeps only settings flagged with a truthy + * `show_in_abilities` argument, of a type the settings endpoint supports. Each entry is + * keyed by its exposed name and carries the underlying option name, the settings group, + * and a JSON Schema describing the value. + * + * @since 7.2.0 + * + * @return array}> Settings keyed by exposed name. + */ + private function get_exposed_settings(): array { + $settings = array(); + + foreach ( get_registered_settings() as $option_name => $args ) { + $show = $args['show_in_abilities'] ?? false; + if ( empty( $show ) ) { + continue; + } + + $schema = $this->value_schema( $args, $show ); + if ( ! in_array( $schema['type'], array( 'number', 'integer', 'string', 'boolean', 'array', 'object' ), true ) ) { + continue; + } + + $option_name = (string) $option_name; + + $settings[ empty( $show['name'] ) ? $option_name : $show['name'] ] = array( + 'option' => $option_name, + 'group' => $args['group'] ?? '', + 'schema' => $schema, + ); + } + + return $settings; + } + + /** + * Builds the JSON Schema describing a single setting's value. + * + * As in the settings endpoint, objects in the schema reject properties they do not declare, + * unless the schema allows them. + * + * @since 7.2.0 + * + * @param array $args The setting registration arguments. + * @param bool|array $show The setting's `show_in_abilities` value. + * @return array The value JSON Schema. + */ + private function value_schema( array $args, $show ): array { + $schema = array( + 'type' => $args['type'], + ); + if ( ! empty( $args['label'] ) ) { + $schema['title'] = $args['label']; + } + if ( ! empty( $args['description'] ) ) { + $schema['description'] = $args['description']; + } + if ( isset( $show['schema'] ) && is_array( $show['schema'] ) ) { + /** @var array $show_schema */ + $show_schema = $show['schema']; + $schema = array_merge( $schema, $show_schema ); + } + + return rest_default_additional_properties_to_false( $schema ); + } +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index cad7014e1f51e..2d3b6d027d684 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -555,6 +555,7 @@ // Abilities API. add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); +add_action( 'wp_abilities_api_init', '_wp_register_initial_settings_for_abilities', 1 ); add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); // Connectors API. diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index d5c179c645af3..6e51021e313b9 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2734,24 +2734,26 @@ function set_site_transient( $transient, $value, $expiration = 0 ) { /** * Registers default settings available in WordPress. * - * The settings registered here are primarily useful for the REST API, so this - * does not encompass all settings available in WordPress. + * The settings registered here are primarily useful for the REST API and the + * Abilities API, so this does not encompass all settings available in WordPress. * * @since 4.7.0 * @since 6.0.1 The `show_on_front`, `page_on_front`, and `page_for_posts` options were added. * @since 7.2.0 The `wp_page_for_privacy_policy` option was registered, exposed as `page_for_privacy_policy`. + * @since 7.2.0 Added `show_in_abilities` support for the exposed settings. */ function register_initial_settings() { register_setting( 'general', 'blogname', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'title', ), - 'type' => 'string', - 'label' => __( 'Title' ), - 'description' => __( 'Site title.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'label' => __( 'Title' ), + 'description' => __( 'Site title.' ), ) ); @@ -2759,12 +2761,13 @@ function register_initial_settings() { 'general', 'blogdescription', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'description', ), - 'type' => 'string', - 'label' => __( 'Tagline' ), - 'description' => __( 'Site tagline.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'label' => __( 'Tagline' ), + 'description' => __( 'Site tagline.' ), ) ); @@ -2773,14 +2776,15 @@ function register_initial_settings() { 'general', 'siteurl', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'url', 'schema' => array( 'format' => 'uri', ), ), - 'type' => 'string', - 'description' => __( 'Site URL.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'Site URL.' ), ) ); } @@ -2790,14 +2794,19 @@ function register_initial_settings() { 'general', 'admin_email', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'email', 'schema' => array( 'format' => 'email', ), ), - 'type' => 'string', - 'description' => __( 'This address is used for admin purposes, like new user notification.' ), + 'show_in_abilities' => array( + 'schema' => array( + 'format' => 'email', + ), + ), + 'type' => 'string', + 'description' => __( 'This address is used for admin purposes, like new user notification.' ), ) ); } @@ -2806,11 +2815,12 @@ function register_initial_settings() { 'general', 'timezone_string', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'timezone', ), - 'type' => 'string', - 'description' => __( 'A city in the same timezone as you.' ), + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'A city in the same timezone as you.' ), ) ); @@ -2818,9 +2828,10 @@ function register_initial_settings() { 'general', 'date_format', array( - 'show_in_rest' => true, - 'type' => 'string', - 'description' => __( 'A date format for all date strings.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'A date format for all date strings.' ), ) ); @@ -2828,9 +2839,10 @@ function register_initial_settings() { 'general', 'time_format', array( - 'show_in_rest' => true, - 'type' => 'string', - 'description' => __( 'A time format for all time strings.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'A time format for all time strings.' ), ) ); @@ -2838,9 +2850,10 @@ function register_initial_settings() { 'general', 'start_of_week', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'description' => __( 'A day number of the week that the week should start on.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'description' => __( 'A day number of the week that the week should start on.' ), ) ); @@ -2848,12 +2861,13 @@ function register_initial_settings() { 'general', 'WPLANG', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'language', ), - 'type' => 'string', - 'description' => __( 'WordPress locale code.' ), - 'default' => 'en_US', + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'WordPress locale code.' ), + 'default' => 'en_US', ) ); @@ -2861,10 +2875,11 @@ function register_initial_settings() { 'writing', 'use_smilies', array( - 'show_in_rest' => true, - 'type' => 'boolean', - 'description' => __( 'Convert emoticons like :-) and :-P to graphics on display.' ), - 'default' => true, + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'boolean', + 'description' => __( 'Convert emoticons like :-) and :-P to graphics on display.' ), + 'default' => true, ) ); @@ -2872,9 +2887,10 @@ function register_initial_settings() { 'writing', 'default_category', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'description' => __( 'Default post category.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'description' => __( 'Default post category.' ), ) ); @@ -2882,9 +2898,10 @@ function register_initial_settings() { 'writing', 'default_post_format', array( - 'show_in_rest' => true, - 'type' => 'string', - 'description' => __( 'Default post format.' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'description' => __( 'Default post format.' ), ) ); @@ -2892,11 +2909,12 @@ function register_initial_settings() { 'reading', 'posts_per_page', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'label' => __( 'Maximum posts per page' ), - 'description' => __( 'Blog pages show at most.' ), - 'default' => 10, + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'label' => __( 'Maximum posts per page' ), + 'description' => __( 'Blog pages show at most.' ), + 'default' => 10, ) ); @@ -2904,10 +2922,11 @@ function register_initial_settings() { 'reading', 'show_on_front', array( - 'show_in_rest' => true, - 'type' => 'string', - 'label' => __( 'Show on front' ), - 'description' => __( 'What to show on the front page' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'string', + 'label' => __( 'Show on front' ), + 'description' => __( 'What to show on the front page' ), ) ); @@ -2915,10 +2934,11 @@ function register_initial_settings() { 'reading', 'page_on_front', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'label' => __( 'Page on front' ), - 'description' => __( 'The ID of the page that should be displayed on the front page' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'label' => __( 'Page on front' ), + 'description' => __( 'The ID of the page that should be displayed on the front page' ), ) ); @@ -2926,9 +2946,10 @@ function register_initial_settings() { 'reading', 'page_for_posts', array( - 'show_in_rest' => true, - 'type' => 'integer', - 'description' => __( 'The ID of the page that should display the latest posts' ), + 'show_in_rest' => true, + 'show_in_abilities' => true, + 'type' => 'integer', + 'description' => __( 'The ID of the page that should display the latest posts' ), ) ); @@ -2936,11 +2957,12 @@ function register_initial_settings() { 'reading', 'wp_page_for_privacy_policy', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'name' => 'page_for_privacy_policy', ), - 'type' => 'integer', - 'description' => __( 'The ID of the page that should be displayed as the privacy policy page' ), + 'show_in_abilities' => true, + 'type' => 'integer', + 'description' => __( 'The ID of the page that should be displayed as the privacy policy page' ), ) ); @@ -2948,13 +2970,18 @@ function register_initial_settings() { 'discussion', 'default_ping_status', array( - 'show_in_rest' => array( + 'show_in_rest' => array( + 'schema' => array( + 'enum' => array( 'open', 'closed' ), + ), + ), + 'show_in_abilities' => array( 'schema' => array( 'enum' => array( 'open', 'closed' ), ), ), - 'type' => 'string', - 'description' => __( 'Allow link notifications from other blogs (pingbacks and trackbacks) on new articles.' ), + 'type' => 'string', + 'description' => __( 'Allow link notifications from other blogs (pingbacks and trackbacks) on new articles.' ), ) ); @@ -2962,18 +2989,57 @@ function register_initial_settings() { 'discussion', 'default_comment_status', array( - 'show_in_rest' => array( + 'show_in_rest' => array( 'schema' => array( 'enum' => array( 'open', 'closed' ), ), ), - 'type' => 'string', - 'label' => __( 'Allow comments on new posts' ), - 'description' => __( 'Allow people to submit comments on new posts.' ), + 'show_in_abilities' => array( + 'schema' => array( + 'enum' => array( 'open', 'closed' ), + ), + ), + 'type' => 'string', + 'label' => __( 'Allow comments on new posts' ), + 'description' => __( 'Allow people to submit comments on new posts.' ), ) ); } +/** + * Registers the default settings when the Abilities API initializes. + * + * The default settings are registered on `rest_api_init`, which fires lazily and + * independently of `wp_abilities_api_init`: on cron, WP-CLI, or any request where + * abilities are used before the REST server loads, it may not have fired, or may be + * mid-fire at a priority before register_initial_settings() runs. This makes sure the + * settings exist before the core abilities read them. Registering them again later on + * `rest_api_init` is harmless. + * + * @since 7.2.0 + * @access private + * + * @global array $new_allowed_options + */ +function _wp_register_initial_settings_for_abilities(): void { + global $new_allowed_options; + + if ( did_action( 'rest_api_init' ) && ! doing_action( 'rest_api_init' ) ) { + return; + } + + $allowed_options = $new_allowed_options; + + register_initial_settings(); + + /* + * Registering a setting also allows it on the options screen of its group. Restore + * the list, so saving Settings > General does not try to save `admin_email`, which + * that screen sends as `new_admin_email`. + */ + $new_allowed_options = $allowed_options; +} + /** * Registers a setting and its data. * @@ -2984,6 +3050,7 @@ function register_initial_settings() { * @since 5.5.0 `$new_whitelist_options` was renamed to `$new_allowed_options`. * Please consider writing more inclusive code. * @since 6.6.0 Added the `label` argument. + * @since 7.2.0 Added the `show_in_abilities` argument. * * @global array $new_allowed_options * @global array $wp_registered_settings @@ -3005,6 +3072,9 @@ function register_initial_settings() { * @type bool|array $show_in_rest Whether data associated with this setting should be included in the * REST API. When registering complex settings, this argument may * optionally be an array with a 'schema' key. + * @type bool|array $show_in_abilities Whether this setting should be exposed through the Abilities API. + * Like `$show_in_rest`, it may be an array with 'name' and 'schema' keys. + * Register the setting on `init` or earlier, before abilities initialize. * @type mixed $default Default value when calling `get_option()`. * } */ @@ -3024,6 +3094,7 @@ function register_setting( $option_group, $option_name, $args = array() ) { 'description' => '', 'sanitize_callback' => null, 'show_in_rest' => false, + 'show_in_abilities' => false, ); // Back-compat: old sanitize callback is added. @@ -3207,6 +3278,7 @@ function unregister_setting( $option_group, $option_name, $deprecated = '' ) { * Retrieves an array of registered settings. * * @since 4.7.0 + * @since 7.2.0 Registered setting data includes the `show_in_abilities` argument. * * @global array $wp_registered_settings * @@ -3226,6 +3298,8 @@ function unregister_setting( $option_group, $option_name, $deprecated = '' ) { * @type bool|array $show_in_rest Whether data associated with this setting should be included in the * REST API. When registering complex settings, this argument may * optionally be an array with a 'schema' key. + * @type bool|array $show_in_abilities Whether this setting should be exposed through the Abilities API. + * Like `$show_in_rest`, it may be an array with 'name' and 'schema' keys. * @type mixed $default Default value when calling `get_option()`. Only present when the * setting was registered with a default. * } diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 193d2aa64f6f3..cdd8b0bac315e 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -429,7 +429,7 @@ parameters: count: 1 path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php - - message: '#^Parameter \#3 \$args of function register_setting expects array\{type\?\: string, label\?\: string, description\?\: string, sanitize_callback\?\: \(callable\(\)\: mixed\)\|null, show_in_rest\?\: array\|bool, default\?\: mixed, \.\.\.\}, ''twentyeleven_theme…'' given\.$#' + message: '#^Parameter \#3 \$args of function register_setting expects array\{type\?\: string, label\?\: string, description\?\: string, sanitize_callback\?\: \(callable\(\)\: mixed\)\|null, show_in_rest\?\: array\|bool, show_in_abilities\?\: array\|bool, default\?\: mixed, \.\.\.\}, ''twentyeleven_theme…'' given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-content/themes/twentyeleven/inc/theme-options.php diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php new file mode 100644 index 0000000000000..a9e80102b2815 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -0,0 +1,477 @@ + 'integer', + 'label' => 'Custom Ability Setting', + 'description' => 'A custom setting exposed through the Abilities API.', + 'show_in_abilities' => true, + 'default' => 42, + ) + ); + + // Temporarily remove the unhook functions so we can register core abilities. + 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_core_ability_categories' ); + add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); + do_action( 'wp_abilities_api_categories_init' ); + do_action( 'wp_abilities_api_init' ); + } + + /** + * Tear down after the class. + * + * @since 7.2.0 + */ + public static function tear_down_after_class(): void { + add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); + add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); + + foreach ( wp_get_abilities() as $ability ) { + wp_unregister_ability( $ability->get_name() ); + } + foreach ( wp_get_ability_categories() as $ability_category ) { + wp_unregister_ability_category( $ability_category->get_slug() ); + } + + unregister_setting( 'general', 'core_settings_get_ability_test_option' ); + + global $wp_registered_settings, $wp_actions; + $wp_registered_settings = self::$registered_settings_backup; + if ( null !== self::$rest_api_init_count ) { + $wp_actions['rest_api_init'] = self::$rest_api_init_count; + } + + parent::tear_down_after_class(); + } + + /** + * Registers the core/settings-get ability again inside a faked init action. + * + * The class setup has already registered it through wp_register_core_abilities(), so + * the existing copy is unregistered first. + */ + private function register_ability(): void { + global $wp_current_filter; + + if ( wp_has_ability( 'core/settings-get' ) ) { + wp_unregister_ability( 'core/settings-get' ); + } + + $wp_current_filter[] = 'wp_abilities_api_init'; + try { + ( new WP_Abilities_Settings() )->register(); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Logs in as an administrator so abilities gated behind `manage_options` can run. + */ + private function become_admin(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + } + + /** + * Core settings are exposed even when the abilities registry initializes in a request + * where `rest_api_init` (which registers core's initial settings) has never fired. + * + * The class setup registers the ability with no settings registered up front, so this + * asserts that core registered its initial settings when abilities initialized. + * + * @ticket 64605 + */ + public function test_core_settings_get_exposes_initial_settings_without_rest_api_init(): void { + $ability = wp_get_ability( 'core/settings-get' ); + + $this->assertArrayHasKey( 'blogname', $ability->get_output_schema()['properties'] ); + + $this->become_admin(); + $result = $ability->execute( array( 'fields' => array( 'blogname' ) ) ); + + $this->assertArrayHasKey( 'blogname', $result ); + } + + /** + * Tests that registering initial settings for abilities does not pollute $new_allowed_options. + * + * @ticket 64605 + */ + public function test_register_preserves_new_allowed_options(): void { + global $new_allowed_options; + + $prev_actions_count = $GLOBALS['wp_actions']['rest_api_init'] ?? null; + $prev_allowed_backup = $new_allowed_options; + unset( $GLOBALS['wp_actions']['rest_api_init'] ); + + // Simulate an existing custom setting already in $new_allowed_options. + $new_allowed_options = array( + 'general' => array( 'my_custom_option' ), + ); + + try { + _wp_register_initial_settings_for_abilities(); + + // 'admin_email' must NOT be in $new_allowed_options['general']. + $this->assertNotContains( 'admin_email', $new_allowed_options['general'] ); + // Prior allowed options must be preserved. + $this->assertContains( 'my_custom_option', $new_allowed_options['general'] ); + } finally { + $new_allowed_options = $prev_allowed_backup; + if ( null === $prev_actions_count ) { + unset( $GLOBALS['wp_actions']['rest_api_init'] ); + } else { + $GLOBALS['wp_actions']['rest_api_init'] = $prev_actions_count; + } + } + } + + /** + * Neither settings ability is registered when no setting is exposed to abilities. + * + * @ticket 64605 + */ + public function test_settings_abilities_are_not_registered_without_exposed_settings(): void { + global $wp_registered_settings; + + $registered_settings_backup = $wp_registered_settings; + $wp_registered_settings = array(); + + try { + $this->register_ability(); + + $this->assertFalse( wp_has_ability( 'core/settings-get' ) ); + } finally { + $wp_registered_settings = $registered_settings_backup; + + // Register the ability again for the tests that follow. + $this->register_ability(); + } + } + + /** + * The ability is registered in the `site` category and flagged read-only. + * + * @ticket 64605 + */ + public function test_core_settings_get_ability_is_registered(): void { + $ability = wp_get_ability( 'core/settings-get' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'core/settings-get', $ability->get_name() ); + $this->assertSame( 'site', $ability->get_category() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'] ); + $this->assertFalse( $annotations['destructive'] ); + } + + /** + * The input schema exposes optional `group` and `fields` filters. + * + * @ticket 64605 + */ + public function test_core_settings_get_input_schema_exposes_group_and_fields_filters(): void { + $schema = wp_get_ability( 'core/settings-get' )->get_input_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertEquals( (object) array(), $schema['default'] ); + $this->assertArrayNotHasKey( 'oneOf', $schema ); + + $this->assertContains( 'general', $schema['properties']['group']['enum'] ); + $this->assertContains( 'reading', $schema['properties']['group']['enum'] ); + + $this->assertContains( 'blogname', $schema['properties']['fields']['items']['enum'] ); + $this->assertContains( 'posts_per_page', $schema['properties']['fields']['items']['enum'] ); + $this->assertContains( 'wp_page_for_privacy_policy', $schema['properties']['fields']['items']['enum'] ); + } + + /** + * Without input the ability returns a flat map of correctly typed setting values. + * + * @ticket 64605 + */ + public function test_core_settings_get_returns_flat_typed_values(): void { + $this->become_admin(); + + update_option( 'blogname', 'My Test Site' ); + update_option( 'posts_per_page', 7 ); + update_option( 'use_smilies', '1' ); + + $result = wp_get_ability( 'core/settings-get' )->execute( array() ); + + $this->assertIsArray( $result ); + $this->assertSame( 'My Test Site', $result['blogname'] ); + $this->assertSame( 7, $result['posts_per_page'] ); + $this->assertTrue( $result['use_smilies'] ); + } + + /** + * The `group` filter narrows the response to a single settings group. + * + * @ticket 64605 + */ + public function test_core_settings_get_filters_by_group(): void { + $this->become_admin(); + + $result = wp_get_ability( 'core/settings-get' )->execute( array( 'group' => 'reading' ) ); + + $this->assertArrayHasKey( 'posts_per_page', $result ); + $this->assertArrayNotHasKey( 'blogname', $result ); + } + + /** + * The `fields` filter narrows the response to the requested setting names. + * + * @ticket 64605 + */ + public function test_core_settings_get_filters_by_fields(): void { + $this->become_admin(); + + $result = wp_get_ability( 'core/settings-get' )->execute( array( 'fields' => array( 'blogname', 'posts_per_page' ) ) ); + + $this->assertEqualSets( array( 'blogname', 'posts_per_page' ), array_keys( $result ) ); + } + + /** + * Supplying both `group` and `fields` narrows the response to their intersection. + * + * @ticket 64605 + */ + public function test_core_settings_get_combines_group_and_fields_filters(): void { + $this->become_admin(); + + // `blogname` is in the `general` group and `posts_per_page` in `reading`; only the + // latter satisfies both filters. + $result = wp_get_ability( 'core/settings-get' )->execute( + array( + 'group' => 'reading', + 'fields' => array( 'blogname', 'posts_per_page' ), + ) + ); + + $this->assertEqualSets( array( 'posts_per_page' ), array_keys( $result ) ); + } + + /** + * Input passed as an object is filtered like input passed as an array. + * + * @ticket 64605 + */ + public function test_core_settings_get_filters_object_input(): void { + $this->become_admin(); + + $result = wp_get_ability( 'core/settings-get' )->execute( (object) array( 'group' => 'reading' ) ); + + $this->assertArrayHasKey( 'posts_per_page', $result ); + $this->assertArrayNotHasKey( 'blogname', $result ); + } + + /** + * Users without `manage_options` cannot run the ability. + * + * @ticket 64605 + */ + public function test_core_settings_get_requires_manage_options(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $result = wp_get_ability( 'core/settings-get' )->execute( array() ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + /** + * A setting registered with `show_in_abilities` (for example by a plugin) is exposed by the ability. + * + * @ticket 64605 + */ + public function test_core_settings_get_exposes_a_custom_registered_setting(): void { + $ability = wp_get_ability( 'core/settings-get' ); + + // Present in both the input `fields` enum and the output schema built at registration. + $this->assertContains( 'core_settings_get_ability_test_option', $ability->get_input_schema()['properties']['fields']['items']['enum'] ); + $this->assertArrayHasKey( 'core_settings_get_ability_test_option', $ability->get_output_schema()['properties'] ); + + // And returned, correctly typed, by execute. + $this->become_admin(); + update_option( 'core_settings_get_ability_test_option', 7 ); + + $result = $ability->execute( array( 'fields' => array( 'core_settings_get_ability_test_option' ) ) ); + + $this->assertSame( array( 'core_settings_get_ability_test_option' => 7 ), $result ); + } + + /** + * A value that does not match its schema is left out instead of failing the whole call. + * + * @ticket 64605 + */ + public function test_core_settings_get_drops_values_that_fail_their_schema(): void { + $this->become_admin(); + + // sanitize_option() only coerces '0' and '' to 'closed', so this out-of-enum value sticks. + update_option( 'default_ping_status', 'not-a-valid-status' ); + + $result = wp_get_ability( 'core/settings-get' )->execute( array() ); + + $this->assertNotWPError( $result, 'One bad value must not fail the whole ability.' ); + $this->assertArrayHasKey( 'blogname', $result, 'The other settings should still be returned.' ); + $this->assertArrayNotHasKey( 'default_ping_status', $result, 'Only the bad value should be left out.' ); + } + + /** + * Stored values are read as the settings endpoint reads them: validated against their schema, + * left out when it rejects them, and sanitized otherwise. + * + * @ticket 64605 + * + * @dataProvider data_stored_values + * + * @param string $type The setting type. + * @param mixed $stored The stored option value. + * @param string|null $expected The value as JSON, or null when it is left out. + * @param array $schema Optional. The `show_in_abilities` schema of the setting. Default empty array. + */ + public function test_core_settings_get_reads_stored_values_as_the_settings_endpoint( string $type, $stored, ?string $expected, array $schema = array() ): void { + $option = 'core_settings_get_ability_value_test_option'; + + register_setting( + 'general', + $option, + array( + 'type' => $type, + 'show_in_abilities' => array( 'schema' => $schema ), + ) + ); + update_option( $option, $stored ); + + try { + $this->register_ability(); + $this->become_admin(); + + $result = wp_get_ability( 'core/settings-get' )->execute( array( 'fields' => array( $option ) ) ); + } finally { + unregister_setting( 'general', $option ); + $this->register_ability(); + } + + $this->assertSame( $expected, isset( $result[ $option ] ) ? wp_json_encode( $result[ $option ] ) : null ); + } + + /** + * Data provider. + * + * @return array}> Stored values, and the JSON they are read as. + */ + public static function data_stored_values(): array { + return array( + '"false" for a boolean' => array( 'boolean', 'false', 'false' ), + 'an empty string for a boolean' => array( 'boolean', '', 'false' ), + 'a stdClass for an object' => array( + 'object', + (object) array( 'a' => 1 ), + '{"a":1}', + array( 'properties' => array( 'a' => array( 'type' => 'integer' ) ) ), + ), + 'an undeclared property in an object' => array( 'object', array( 'a' => 1 ), null ), + 'an empty array for an object' => array( 'object', array(), '{}' ), + 'a list with gaps for an array' => array( + 'array', + array( + 0 => 'a', + 2 => 'b', + ), + '["a","b"]', + ), + 'a numeric string for an integer' => array( 'integer', '7', '7' ), + 'a non-numeric string for an integer' => array( 'integer', 'abc', null ), + ); + } + + /** + * A setting of a type the settings endpoint does not support is not exposed. + * + * @ticket 64605 + */ + public function test_core_settings_get_skips_a_setting_with_an_unsupported_type(): void { + $option = 'core_settings_get_ability_type_test_option'; + + register_setting( + 'general', + $option, + array( + 'type' => 'foo', + 'show_in_abilities' => true, + ) + ); + update_option( $option, 'value' ); + + try { + $this->register_ability(); + $this->become_admin(); + + $ability = wp_get_ability( 'core/settings-get' ); + + $this->assertArrayNotHasKey( $option, $ability->get_output_schema()['properties'] ); + $this->assertArrayNotHasKey( $option, $ability->execute( array() ) ); + } finally { + unregister_setting( 'general', $option ); + $this->register_ability(); + } + } +} diff --git a/tests/phpunit/tests/option/registration.php b/tests/phpunit/tests/option/registration.php index 850376498e91a..48ee025d2536d 100644 --- a/tests/phpunit/tests/option/registration.php +++ b/tests/phpunit/tests/option/registration.php @@ -20,6 +20,7 @@ public function test_register() { // Check defaults. $this->assertSame( 'string', $args['type'] ); $this->assertFalse( $args['show_in_rest'] ); + $this->assertFalse( $args['show_in_abilities'] ); $this->assertSame( '', $args['description'] ); }