diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index f2826e54baf4e..ef750a4632aaf 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -12,9 +12,9 @@ /** * Core class used to register settings-related abilities. * - * Provides the read-only `core/settings` ability and the shared building blocks - * (exposed-settings discovery, schema generation, value casting) that are intended to - * also back a future write-oriented `core/manage-settings` ability. + * Provides the read-only `core/settings` ability and the write-oriented `core/manage-settings` + * ability, plus the shared building blocks (exposed-settings discovery, schema generation, value + * casting) that back both. * * This class is part of WordPress' internal implementation of the core abilities and is * not part of the public API. It may be changed or removed at any time without notice. @@ -54,13 +54,7 @@ final class WP_Settings_Abilities { */ public function register(): void { $this->register_get_settings(); - - /* - * A future write-oriented ability can be registered here, reusing the shared - * helpers below (get_exposed_settings(), value_schema(), cast_value()): - * - * $this->register_manage_settings(); - */ + $this->register_manage_settings(); } /** @@ -111,6 +105,54 @@ private function register_get_settings(): void { ); } + /** + * Registers the write-oriented `core/manage-settings` ability. + * + * The input and output schemas reuse each exposed setting's own schema, so every setting + * readable via `core/settings` is also writable through this ability. + * + * @since 7.1.0 + */ + private function register_manage_settings(): void { + $settings = (array) $this->exposed_settings; + $properties = array(); + foreach ( $settings as $exposed_name => $setting ) { + $properties[ $exposed_name ] = $setting['schema']; + } + + wp_register_ability( + 'core/manage-settings', + array( + 'label' => __( 'Manage Settings' ), + 'description' => __( 'Updates one or more WordPress settings exposed to abilities. Accepts a map of setting name to its new value and returns the updated values.' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'description' => __( 'A map of setting name to the new value to store. At least one setting is required.' ), + 'properties' => $properties, + 'minProperties' => 1, + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'A map of each updated setting name to its new value.' ), + 'properties' => $properties, + 'additionalProperties' => false, + ), + 'execute_callback' => array( $this, 'execute_manage_settings' ), + 'permission_callback' => array( $this, 'has_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + /** * Executes the `core/settings` ability. * @@ -150,6 +192,53 @@ public function execute_get_settings( $input = array() ): array { return $result; } + /** + * Executes the `core/manage-settings` ability. + * + * The Abilities API validates the input against the registered input schema (each setting's own + * value schema, with `additionalProperties` disabled) before this runs, so every value reaching + * here is known and valid; an invalid value aborts the call before any option is written. Each + * value is sanitized against its schema and stored, then read back and cast for the response. + * + * @since 7.1.0 + * + * @param mixed $input The ability input: a map of exposed setting name to its new value. + * @return array Map of each updated setting name to its stored value. + */ + public function execute_manage_settings( $input = array() ): array { + $input = is_array( $input ) ? $input : array(); + + $settings = $this->exposed_settings; + if ( null === $settings ) { + // The cache is populated in register_get_settings() before the ability is + // registered, so this is unreachable in practice; bail defensively otherwise. + return array(); + } + + $result = array(); + foreach ( $input as $exposed_name => $value ) { + if ( ! is_string( $exposed_name ) || ! isset( $settings[ $exposed_name ] ) ) { + // `additionalProperties: false` already rejects unknown keys upstream; guard defensively. + continue; + } + + $setting = $settings[ $exposed_name ]; + + // Sanitize against the declared schema before storing; update_option() additionally + // runs the setting's own registered sanitize_callback. + $value = rest_sanitize_value_from_schema( $value, $setting['schema'], $exposed_name ); + + update_option( $setting['option'], $value ); + + $type = isset( $setting['schema']['type'] ) && is_string( $setting['schema']['type'] ) ? $setting['schema']['type'] : 'string'; + $stored = get_option( $setting['option'], $setting['default'] ); + + $result[ $exposed_name ] = $this->cast_value( $stored, $type ); + } + + return $result; + } + /** * Checks whether the current user may use the settings abilities. * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreManageSettingsAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreManageSettingsAbility.php new file mode 100644 index 0000000000000..36ebce4f81980 --- /dev/null +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreManageSettingsAbility.php @@ -0,0 +1,226 @@ + '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.1.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_ability_test_option' ); + + parent::tear_down_after_class(); + } + + /** + * 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' ) ) ); + } + + /** + * The ability is registered in the `site` category and flagged writable. + * + * @ticket 64146 + */ + public function test_core_manage_settings_ability_is_registered(): void { + $ability = wp_get_ability( 'core/manage-settings' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'site', $ability->get_category() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertFalse( $annotations['readonly'] ); + $this->assertFalse( $annotations['destructive'] ); + } + + /** + * Every setting exposed for reading is writable: the input schema mirrors the exposed set + * and disallows unknown properties. + * + * @ticket 64146 + */ + public function test_core_manage_settings_input_schema_mirrors_exposed_settings(): void { + $schema = wp_get_ability( 'core/manage-settings' )->get_input_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertFalse( $schema['additionalProperties'] ); + $this->assertSame( 1, $schema['minProperties'] ); + $this->assertArrayHasKey( 'blogname', $schema['properties'] ); + $this->assertArrayHasKey( 'posts_per_page', $schema['properties'] ); + } + + /** + * The ability stores each provided setting and returns the updated, correctly typed values. + * + * @ticket 64146 + */ + public function test_core_manage_settings_updates_and_returns_values(): void { + $this->become_admin(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( + 'blogname' => 'Renamed Site', + 'posts_per_page' => 9, + ) + ); + + $this->assertSame( + array( + 'blogname' => 'Renamed Site', + 'posts_per_page' => 9, + ), + $result + ); + // Persisted to the database. + $this->assertSame( 'Renamed Site', get_option( 'blogname' ) ); + $this->assertSame( 9, (int) get_option( 'posts_per_page' ) ); + } + + /** + * An invalid value aborts the whole call before any option is written (all-or-nothing). + * + * @ticket 64146 + */ + public function test_core_manage_settings_is_atomic_on_invalid_value(): void { + $this->become_admin(); + + update_option( 'blogname', 'Original Name' ); + + // `default_ping_status` is constrained to the enum open|closed; `sometimes` is invalid, so + // the whole call must fail and the valid sibling value must not be written. + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( + 'blogname' => 'Should Not Persist', + 'default_ping_status' => 'sometimes', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + $this->assertSame( 'Original Name', get_option( 'blogname' ) ); + } + + /** + * Unknown setting names are rejected by `additionalProperties: false`. + * + * @ticket 64146 + */ + public function test_core_manage_settings_rejects_unknown_setting(): void { + $this->become_admin(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( 'not_a_registered_setting' => 'value' ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /** + * Empty input is rejected: at least one setting must be provided. + * + * @ticket 64146 + */ + public function test_core_manage_settings_rejects_empty_input(): void { + $this->become_admin(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( array() ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /** + * Users without `manage_options` cannot run the ability, and nothing is written. + * + * @ticket 64146 + */ + public function test_core_manage_settings_requires_manage_options(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + update_option( 'blogname', 'Original Name' ); + + $result = wp_get_ability( 'core/manage-settings' )->execute( array( 'blogname' => 'Nope' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + $this->assertSame( 'Original Name', get_option( 'blogname' ) ); + } + + /** + * A setting registered with `show_in_abilities` (for example by a plugin) is writable. + * + * @ticket 64146 + */ + public function test_core_manage_settings_updates_a_custom_registered_setting(): void { + $this->become_admin(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( 'core_settings_ability_test_option' => 100 ) + ); + + $this->assertSame( array( 'core_settings_ability_test_option' => 100 ), $result ); + $this->assertSame( 100, (int) get_option( 'core_settings_ability_test_option' ) ); + } +}