From 5fb39657a7429f570dcb313fedffbae7993e99b0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 17 Feb 2026 11:14:18 +0000 Subject: [PATCH 01/33] Update: Support settings schema on get settings. --- .../abilities/class-wp-settings-abilities.php | 7 ++- src/wp-includes/option.php | 24 ++++++-- .../wpRestAbilitiesSettingsController.php | 61 +++++++++++++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 5af7fa48450ee..463efc1fe9293 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -137,7 +137,7 @@ private static function get_available_slugs(): array { * * Creates a JSON Schema that documents each setting group and its settings * with their types, titles, descriptions, defaults, and any additional - * schema properties from show_in_rest. + * schema properties from show_in_abilities. * * @since 7.0.0 * @@ -163,6 +163,11 @@ private static function build_output_schema(): array { $setting_schema['description'] = $args['label']; } + // Merge custom schema from show_in_abilities if provided as an array. + if ( is_array( $args['show_in_abilities'] ) && ! empty( $args['show_in_abilities']['schema'] ) ) { + $setting_schema = array_merge( $setting_schema, $args['show_in_abilities']['schema'] ); + } + if ( ! isset( $group_properties[ $group ] ) ) { $group_properties[ $group ] = array( 'type' => 'object', diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 8a9a2c3c89ece..acd97e2a24395 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2778,7 +2778,11 @@ function register_initial_settings() { 'format' => 'uri', ), ), - 'show_in_abilities' => true, + 'show_in_abilities' => array( + 'schema' => array( + 'format' => 'uri', + ), + ), 'type' => 'string', 'description' => __( 'Site URL.' ), ) @@ -2796,7 +2800,11 @@ function register_initial_settings() { 'format' => 'email', ), ), - 'show_in_abilities' => true, + 'show_in_abilities' => array( + 'schema' => array( + 'format' => 'email', + ), + ), 'type' => 'string', 'description' => __( 'This address is used for admin purposes, like new user notification.' ), ) @@ -2954,7 +2962,11 @@ function register_initial_settings() { 'enum' => array( 'open', 'closed' ), ), ), - 'show_in_abilities' => true, + '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.' ), ) @@ -2969,7 +2981,11 @@ function register_initial_settings() { 'enum' => array( 'open', 'closed' ), ), ), - 'show_in_abilities' => true, + '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.' ), diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 198c0c3b8bc69..81b2e49c48584 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -353,4 +353,65 @@ public function test_core_get_settings_returns_correct_values(): void { $this->assertSame( 'Test Site Name', $data['general']['blogname'] ); } + + /** + * Tests that settings with enum schema in show_in_abilities include it in output schema. + * + * @ticket 64605 + */ + public function test_core_get_settings_output_schema_includes_enum(): void { + $ability = wp_get_ability( 'core/get-settings' ); + $output_schema = $ability->get_output_schema(); + + // Check default_ping_status has enum. + $this->assertArrayHasKey( 'discussion', $output_schema['properties'] ); + $this->assertArrayHasKey( 'default_ping_status', $output_schema['properties']['discussion']['properties'] ); + $this->assertArrayHasKey( 'enum', $output_schema['properties']['discussion']['properties']['default_ping_status'] ); + $this->assertSame( array( 'open', 'closed' ), $output_schema['properties']['discussion']['properties']['default_ping_status']['enum'] ); + + // Check default_comment_status has enum. + $this->assertArrayHasKey( 'default_comment_status', $output_schema['properties']['discussion']['properties'] ); + $this->assertArrayHasKey( 'enum', $output_schema['properties']['discussion']['properties']['default_comment_status'] ); + $this->assertSame( array( 'open', 'closed' ), $output_schema['properties']['discussion']['properties']['default_comment_status']['enum'] ); + } + + /** + * Tests that boolean show_in_abilities (true) still works correctly. + * + * @ticket 64605 + */ + public function test_core_get_settings_boolean_show_in_abilities_still_works(): void { + $ability = wp_get_ability( 'core/get-settings' ); + $output_schema = $ability->get_output_schema(); + + // blogname uses show_in_abilities => true (boolean). + $this->assertArrayHasKey( 'general', $output_schema['properties'] ); + $this->assertArrayHasKey( 'blogname', $output_schema['properties']['general']['properties'] ); + $this->assertSame( 'string', $output_schema['properties']['general']['properties']['blogname']['type'] ); + } + + /** + * Tests that custom show_in_abilities schema preserves base schema properties while adding custom ones. + * + * @ticket 64605 + */ + public function test_core_get_settings_output_schema_preserves_base_schema(): void { + $ability = wp_get_ability( 'core/get-settings' ); + $output_schema = $ability->get_output_schema(); + + // default_comment_status has show_in_abilities with schema but also has label and description. + $this->assertArrayHasKey( 'discussion', $output_schema['properties'] ); + $this->assertArrayHasKey( 'default_comment_status', $output_schema['properties']['discussion']['properties'] ); + + $setting_schema = $output_schema['properties']['discussion']['properties']['default_comment_status']; + + // Verify base schema properties are preserved. + $this->assertSame( 'string', $setting_schema['type'] ); + $this->assertArrayHasKey( 'title', $setting_schema ); + $this->assertArrayHasKey( 'description', $setting_schema ); + + // Verify custom schema property (enum) is merged. + $this->assertArrayHasKey( 'enum', $setting_schema ); + $this->assertSame( array( 'open', 'closed' ), $setting_schema['enum'] ); + } } From fc9b98fa0dd970e83b1ffc2ee3613703a62e25bd Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 17 Feb 2026 11:23:04 +0000 Subject: [PATCH 02/33] Test invalid output shcema validation --- .../wpRestAbilitiesSettingsController.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 81b2e49c48584..65d17b0eb3365 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -414,4 +414,28 @@ public function test_core_get_settings_output_schema_preserves_base_schema(): vo $this->assertArrayHasKey( 'enum', $setting_schema ); $this->assertSame( array( 'open', 'closed' ), $setting_schema['enum'] ); } + + /** + * Tests that ability returns error when setting value violates schema enum. + * + * @ticket 64605 + */ + public function test_core_get_settings_returns_error_for_invalid_enum_value(): void { + // Set an invalid value for default_ping_status (violates enum: ['open', 'closed']). + update_option( 'default_ping_status', 'invalid_value' ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $request->set_query_params( + array( + 'input' => array( + 'slugs' => array( 'default_ping_status' ), + ), + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 500, $response->get_status() ); + $data = $response->get_data(); + $this->assertSame( 'ability_invalid_output', $data['code'] ); + } } From daebd4bf2b15f629882fbb81b6dd4783ba1283e5 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 17 Feb 2026 11:38:36 +0000 Subject: [PATCH 03/33] remove schema duplocation --- src/wp-includes/option.php | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index acd97e2a24395..94008571a6b65 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2768,20 +2768,17 @@ function register_initial_settings() { ); if ( ! is_multisite() ) { + $uri_schema = array( 'format' => 'uri' ); register_setting( 'general', 'siteurl', array( 'show_in_rest' => array( 'name' => 'url', - 'schema' => array( - 'format' => 'uri', - ), + 'schema' => $uri_schema, ), 'show_in_abilities' => array( - 'schema' => array( - 'format' => 'uri', - ), + 'schema' => $uri_schema, ), 'type' => 'string', 'description' => __( 'Site URL.' ), @@ -2790,20 +2787,17 @@ function register_initial_settings() { } if ( ! is_multisite() ) { + $email_schema = array( 'format' => 'email' ); register_setting( 'general', 'admin_email', array( 'show_in_rest' => array( 'name' => 'email', - 'schema' => array( - 'format' => 'email', - ), + 'schema' => $email_schema, ), 'show_in_abilities' => array( - 'schema' => array( - 'format' => 'email', - ), + 'schema' => $email_schema, ), 'type' => 'string', 'description' => __( 'This address is used for admin purposes, like new user notification.' ), @@ -2953,19 +2947,17 @@ function register_initial_settings() { ) ); + $open_closed_enum_schema = array( 'enum' => array( 'open', 'closed' ) ); + register_setting( 'discussion', 'default_ping_status', array( 'show_in_rest' => array( - 'schema' => array( - 'enum' => array( 'open', 'closed' ), - ), + 'schema' => $open_closed_enum_schema, ), 'show_in_abilities' => array( - 'schema' => array( - 'enum' => array( 'open', 'closed' ), - ), + 'schema' => $open_closed_enum_schema, ), 'type' => 'string', 'description' => __( 'Allow link notifications from other blogs (pingbacks and trackbacks) on new articles.' ), @@ -2977,14 +2969,10 @@ function register_initial_settings() { 'default_comment_status', array( 'show_in_rest' => array( - 'schema' => array( - 'enum' => array( 'open', 'closed' ), - ), + 'schema' => $open_closed_enum_schema, ), 'show_in_abilities' => array( - 'schema' => array( - 'enum' => array( 'open', 'closed' ), - ), + 'schema' => $open_closed_enum_schema, ), 'type' => 'string', 'label' => __( 'Allow comments on new posts' ), From 9051fa5208169c7f4279bbf8b868d8ef93bb9e19 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 17 Feb 2026 16:38:14 +0000 Subject: [PATCH 04/33] Docs: Clarify register_setting show_in_abilities schema arg --- src/wp-includes/option.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 94008571a6b65..fbd650785c208 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -3010,8 +3010,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 $show_in_abilities Whether this setting should be exposed through the Abilities API. - * Default false. + * @type bool|array $show_in_abilities Whether this setting should be exposed through the Abilities API. + * When registering complex settings, this argument may optionally be an + * array with a 'schema' key. Default false. * @type mixed $default Default value when calling `get_option()`. * } */ From a808c85306d12eb85fc8c07487ac9a5221b19c7b Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Tue, 17 Feb 2026 13:20:36 +0000 Subject: [PATCH 05/33] Block Supports: Add autoRegister support for PHP-only block registration. Introduces block support for PHP-only block registration, enabling developers to register blocks in PHP without requiring JavaScript registration code. When a block declares `'supports' => array( 'autoRegister' => true )` along with a render callback, it is exposed to the client-side via a JavaScript global variable and registered automatically. Example usage: {{{ register_block_type( 'my-plugin/example', array( 'title' => 'My Example Block', 'attributes' => array( 'title' => array( 'type' => 'string', 'default' => 'Hello World', ), 'count' => array( 'type' => 'integer', 'default' => 5, ), ), 'render_callback' => function ( $attributes ) { return sprintf( '
%2$s: %3$d items
', get_block_wrapper_attributes(), esc_html( $attributes['title'] ), $attributes['count'] ); }, 'supports' => array( 'autoRegister' => true, ), ) ); }}} Props mcsf, oandregal, ramonopoly, westonruter, wildworks. Fixes #64639. git-svn-id: https://develop.svn.wordpress.org/trunk@61661 602fd350-edb4-49c9-b593-d223f7449a82 --- .../block-supports/auto-register.php | 61 ++++++++ src/wp-includes/blocks.php | 28 ++++ src/wp-includes/default-filters.php | 1 + src/wp-settings.php | 1 + .../tests/block-supports/auto-register.php | 144 ++++++++++++++++++ 5 files changed, 235 insertions(+) create mode 100644 src/wp-includes/block-supports/auto-register.php create mode 100644 tests/phpunit/tests/block-supports/auto-register.php diff --git a/src/wp-includes/block-supports/auto-register.php b/src/wp-includes/block-supports/auto-register.php new file mode 100644 index 0000000000000..83feedaaeeade --- /dev/null +++ b/src/wp-includes/block-supports/auto-register.php @@ -0,0 +1,61 @@ + $args Array of arguments for registering a block type. + * @return array Modified block type arguments. + */ +function wp_mark_auto_generate_control_attributes( array $args ): array { + if ( empty( $args['attributes'] ) || ! is_array( $args['attributes'] ) ) { + return $args; + } + + $has_auto_register = ! empty( $args['supports']['autoRegister'] ); + if ( ! $has_auto_register ) { + return $args; + } + + foreach ( $args['attributes'] as $attr_key => $attr_schema ) { + // Skip HTML-derived attributes (edited inline, not via inspector). + if ( ! empty( $attr_schema['source'] ) ) { + continue; + } + // Skip internal attributes (not user-configurable). + if ( isset( $attr_schema['role'] ) && 'local' === $attr_schema['role'] ) { + continue; + } + // Skip unsupported types (only 'string', 'number', 'integer', 'boolean' are supported). + $type = $attr_schema['type'] ?? null; + if ( ! in_array( $type, array( 'string', 'number', 'integer', 'boolean' ), true ) ) { + continue; + } + $args['attributes'][ $attr_key ]['autoGenerateControl'] = true; + } + + return $args; +} + +// Priority 5 to mark original attributes before other filters (priority 10+) might add their own. +add_filter( 'register_block_type_args', 'wp_mark_auto_generate_control_attributes', 5 ); diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index e7042b8266e54..89007d0d0d036 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -3131,3 +3131,31 @@ function _wp_footnotes_force_filtered_html_on_import_filter( $arg ) { } return $arg; } + +/** + * Exposes blocks with autoRegister flag for ServerSideRender in the editor. + * + * Detects blocks that have the autoRegister flag set in their supports + * and passes them to JavaScript for auto-registration with ServerSideRender. + * + * @access private + * @since 7.0.0 + */ +function _wp_enqueue_auto_register_blocks() { + $auto_register_blocks = array(); + $registered_blocks = WP_Block_Type_Registry::get_instance()->get_all_registered(); + + foreach ( $registered_blocks as $block_name => $block_type ) { + if ( ! empty( $block_type->supports['autoRegister'] ) && ! empty( $block_type->render_callback ) ) { + $auto_register_blocks[] = $block_name; + } + } + + if ( ! empty( $auto_register_blocks ) ) { + wp_add_inline_script( + 'wp-block-library', + sprintf( 'window.__unstableAutoRegisterBlocks = %s;', wp_json_encode( $auto_register_blocks ) ), + 'before' + ); + } +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index de0b374ef4b56..cc010a7b62202 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -623,6 +623,7 @@ add_action( 'enqueue_block_editor_assets', 'wp_enqueue_editor_format_library_assets' ); add_action( 'enqueue_block_editor_assets', 'wp_enqueue_block_editor_script_modules' ); add_action( 'enqueue_block_editor_assets', 'wp_enqueue_global_styles_css_custom_properties' ); +add_action( 'enqueue_block_editor_assets', '_wp_enqueue_auto_register_blocks' ); add_action( 'wp_print_scripts', 'wp_just_in_time_script_localization' ); add_filter( 'print_scripts_array', 'wp_prototype_before_jquery' ); add_action( 'customize_controls_print_styles', 'wp_resource_hints', 1 ); diff --git a/src/wp-settings.php b/src/wp-settings.php index 60c220100f539..0014dc692200d 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -394,6 +394,7 @@ require ABSPATH . WPINC . '/class-wp-block-supports.php'; require ABSPATH . WPINC . '/block-supports/utils.php'; require ABSPATH . WPINC . '/block-supports/align.php'; +require ABSPATH . WPINC . '/block-supports/auto-register.php'; require ABSPATH . WPINC . '/block-supports/custom-classname.php'; require ABSPATH . WPINC . '/block-supports/generated-classname.php'; require ABSPATH . WPINC . '/block-supports/settings.php'; diff --git a/tests/phpunit/tests/block-supports/auto-register.php b/tests/phpunit/tests/block-supports/auto-register.php new file mode 100644 index 0000000000000..71754f247225b --- /dev/null +++ b/tests/phpunit/tests/block-supports/auto-register.php @@ -0,0 +1,144 @@ + array( 'autoRegister' => true ), + 'attributes' => array( + 'title' => array( 'type' => 'string' ), + 'count' => array( 'type' => 'integer' ), + ), + ); + + $result = wp_mark_auto_generate_control_attributes( $settings ); + + $this->assertTrue( $result['attributes']['title']['autoGenerateControl'] ); + $this->assertTrue( $result['attributes']['count']['autoGenerateControl'] ); + } + + /** + * Tests that attributes are not marked without autoRegister flag. + * + * @ticket 64639 + */ + public function test_does_not_mark_attributes_without_auto_register() { + $settings = array( + 'attributes' => array( + 'title' => array( 'type' => 'string' ), + ), + ); + + $result = wp_mark_auto_generate_control_attributes( $settings ); + + $this->assertArrayNotHasKey( 'autoGenerateControl', $result['attributes']['title'] ); + } + + /** + * Tests that attributes with source are excluded. + * + * @ticket 64639 + */ + public function test_excludes_attributes_with_source() { + $settings = array( + 'supports' => array( 'autoRegister' => true ), + 'attributes' => array( + 'title' => array( 'type' => 'string' ), + 'content' => array( + 'type' => 'string', + 'source' => 'html', + ), + ), + ); + + $result = wp_mark_auto_generate_control_attributes( $settings ); + + $this->assertTrue( $result['attributes']['title']['autoGenerateControl'] ); + $this->assertArrayNotHasKey( 'autoGenerateControl', $result['attributes']['content'] ); + } + + /** + * Tests that attributes with role: local are excluded. + * + * Example: The 'blob' attribute in media blocks (image, video, file, audio) + * stores a temporary blob URL during file upload. This is internal state + * that shouldn't be shown in the inspector or saved to the database. + * + * @ticket 64639 + */ + public function test_excludes_attributes_with_role_local() { + $settings = array( + 'supports' => array( 'autoRegister' => true ), + 'attributes' => array( + 'title' => array( 'type' => 'string' ), + 'blob' => array( + 'type' => 'string', + 'role' => 'local', + ), + ), + ); + + $result = wp_mark_auto_generate_control_attributes( $settings ); + + $this->assertTrue( $result['attributes']['title']['autoGenerateControl'] ); + $this->assertArrayNotHasKey( 'autoGenerateControl', $result['attributes']['blob'] ); + } + + /** + * Tests that empty attributes are handled gracefully. + * + * @ticket 64639 + */ + public function test_handles_empty_attributes() { + $settings = array( + 'supports' => array( 'autoRegister' => true ), + ); + + $result = wp_mark_auto_generate_control_attributes( $settings ); + + $this->assertSame( $settings, $result ); + } + + /** + * Tests that only allowed attributes are marked. + * + * @ticket 64639 + */ + public function test_excludes_unsupported_types() { + $settings = array( + 'supports' => array( 'autoRegister' => true ), + 'attributes' => array( + // Supported types + 'text' => array( 'type' => 'string' ), + 'price' => array( 'type' => 'number' ), + 'count' => array( 'type' => 'integer' ), + 'enabled' => array( 'type' => 'boolean' ), + // Unsupported types + 'metadata' => array( 'type' => 'object' ), + 'items' => array( 'type' => 'array' ), + 'config' => array( 'type' => 'null' ), + 'unknown' => array( 'type' => 'unknown' ), + ), + ); + + $result = wp_mark_auto_generate_control_attributes( $settings ); + + $this->assertTrue( $result['attributes']['text']['autoGenerateControl'] ); + $this->assertTrue( $result['attributes']['price']['autoGenerateControl'] ); + $this->assertTrue( $result['attributes']['count']['autoGenerateControl'] ); + $this->assertTrue( $result['attributes']['enabled']['autoGenerateControl'] ); + $this->assertArrayNotHasKey( 'autoGenerateControl', $result['attributes']['metadata'] ); + $this->assertArrayNotHasKey( 'autoGenerateControl', $result['attributes']['items'] ); + $this->assertArrayNotHasKey( 'autoGenerateControl', $result['attributes']['config'] ); + $this->assertArrayNotHasKey( 'autoGenerateControl', $result['attributes']['unknown'] ); + } +} From 296e4403005e442e59038943b0787d5e166ba795 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 18:02:18 +0000 Subject: [PATCH 06/33] initial version --- .../abilities/class-wp-settings-abilities.php | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 463efc1fe9293..8630673c355fd 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -57,6 +57,7 @@ class WP_Settings_Abilities { public static function register(): void { self::init(); self::register_get_settings(); + self::register_update_settings(); } /** @@ -258,6 +259,60 @@ private static function register_get_settings(): void { ); } + /** + * Registers the core/update-settings ability. + * + * @since 7.0.0 + * + * @return void + */ + private static function register_update_settings(): void { + wp_register_ability( + 'core/update-settings', + array( + 'label' => __( 'Update Settings' ), + 'description' => __( 'Updates registered WordPress settings. Only settings with show_in_abilities enabled can be modified.' ), + 'category' => 'site', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'settings' ), + 'properties' => array( + 'settings' => array( + 'type' => 'object', + 'description' => __( 'Settings to update, grouped by registration group. Same structure as returned by core/get-settings.' ), + 'additionalProperties' => true, + ), + ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'updated_settings' => array( + 'type' => 'object', + 'description' => __( 'Settings that were successfully updated, grouped by registration group. Same shape as input.' ), + ), + 'validation_errors' => array( + 'type' => 'object', + 'description' => __( 'Validation errors grouped by registration group. Same shape as input but with error messages as values.' ), + ), + ), + 'additionalProperties' => false, + ), + 'execute_callback' => array( __CLASS__, 'execute_update_settings' ), + 'permission_callback' => array( __CLASS__, 'check_manage_options' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + /** * Permission callback for settings abilities. * @@ -320,6 +375,130 @@ public static function execute_get_settings( $input = array() ): array { return $settings_by_group; } + /** + * Execute callback for core/update-settings ability. + * + * Updates registered settings that are exposed through the Abilities API. + * Returns updated settings and validation errors in a grouped structure + * matching the input format. + * + * @since 7.0.0 + * + * @param array $input { + * Input parameters. + * + * @type array $settings Settings to update, grouped by registration group. + * } + * @return array { + * @type array $updated_settings Settings that were successfully updated, grouped by registration group. + * @type array $validation_errors Validation errors grouped by registration group. + * } + */ + public static function execute_update_settings( $input = array() ): array { + $input = is_array( $input ) ? $input : array(); + + if ( empty( $input['settings'] ) || ! is_array( $input['settings'] ) ) { + return array( + 'updated_settings' => (object) array(), + 'validation_errors' => (object) array(), + ); + } + + $grouped_settings = $input['settings']; + $allowed_settings = self::get_allowed_settings(); + + $updated_settings = array(); + $validation_errors = array(); + + // Iterate through groups (general, reading, writing, etc.). + foreach ( $grouped_settings as $group => $settings ) { + if ( ! is_array( $settings ) ) { + if ( ! isset( $validation_errors[ $group ] ) ) { + $validation_errors[ $group ] = array(); + } + $validation_errors[ $group ]['_error'] = __( 'Group settings must be an object.' ); + continue; + } + + // Iterate through settings within each group. + foreach ( $settings as $option_name => $value ) { + // Check if setting is allowed. + if ( ! isset( $allowed_settings[ $option_name ] ) ) { + if ( ! isset( $validation_errors[ $group ] ) ) { + $validation_errors[ $group ] = array(); + } + $validation_errors[ $group ][ $option_name ] = __( 'Setting is not allowed to be modified.' ); + continue; + } + + $args = $allowed_settings[ $option_name ]; + + // Verify setting belongs to the specified group. + $setting_group = $args['group'] ?? 'general'; + if ( $setting_group !== $group ) { + if ( ! isset( $validation_errors[ $group ] ) ) { + $validation_errors[ $group ] = array(); + } + $validation_errors[ $group ][ $option_name ] = sprintf( + /* translators: 1: Expected group, 2: Provided group. */ + __( 'Setting belongs to group "%1$s", not "%2$s".' ), + $setting_group, + $group + ); + continue; + } + + // Build schema for validation. + $schema = array( + 'type' => $args['type'] ?? 'string', + ); + if ( is_array( $args['show_in_rest'] ) && isset( $args['show_in_rest']['schema'] ) ) { + $schema = array_merge( $schema, $args['show_in_rest']['schema'] ); + } + + // Validate value against schema. + $validation = rest_validate_value_from_schema( $value, $schema, $option_name ); + if ( is_wp_error( $validation ) ) { + if ( ! isset( $validation_errors[ $group ] ) ) { + $validation_errors[ $group ] = array(); + } + $validation_errors[ $group ][ $option_name ] = $validation->get_error_message(); + continue; + } + + // Sanitize the value. + $sanitized_value = rest_sanitize_value_from_schema( $value, $schema ); + + // Apply registered sanitize callback if exists. + if ( ! empty( $args['sanitize_callback'] ) && is_callable( $args['sanitize_callback'] ) ) { + $sanitized_value = call_user_func( $args['sanitize_callback'], $sanitized_value ); + } + + $updated = update_option( $option_name, $sanitized_value ); + if ( $updated || get_option( $option_name ) === $sanitized_value ) { + // Add to updated_settings with the same grouped structure. + if ( ! isset( $updated_settings[ $group ] ) ) { + $updated_settings[ $group ] = array(); + } + $updated_settings[ $group ][ $option_name ] = self::cast_value( + get_option( $option_name ), + $args['type'] ?? 'string' + ); + } else { + if ( ! isset( $validation_errors[ $group ] ) ) { + $validation_errors[ $group ] = array(); + } + $validation_errors[ $group ][ $option_name ] = __( 'Failed to update setting.' ); + } + } + } + + return array( + 'updated_settings' => ! empty( $updated_settings ) ? $updated_settings : (object) array(), + 'validation_errors' => ! empty( $validation_errors ) ? $validation_errors : (object) array(), + ); + } + /** * Casts a value to the appropriate type based on the setting's registered type. * From bcb067e522fe72c5bcb88a1a5aeb6fbc2c8d4061 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 18:02:27 +0000 Subject: [PATCH 07/33] add tests --- .../wpRestAbilitiesSettingsController.php | 457 ++++++++++++++++++ 1 file changed, 457 insertions(+) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 65d17b0eb3365..533dccc0baef5 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -438,4 +438,461 @@ public function test_core_get_settings_returns_error_for_invalid_enum_value(): v $data = $response->get_data(); $this->assertSame( 'ability_invalid_output', $data['code'] ); } + + /** + * Tests that unauthenticated users cannot access the update-settings ability. + * + * @ticket 64605 + */ + public function test_core_update_settings_requires_authentication(): void { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array( + 'general' => array( + 'blogname' => 'New Title', + ), + ), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Tests that subscribers cannot access the update-settings ability. + * + * @ticket 64605 + */ + public function test_core_update_settings_requires_manage_options_capability(): void { + wp_set_current_user( self::$subscriber_id ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array( + 'general' => array( + 'blogname' => 'New Title', + ), + ), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * Tests that administrators can access the update-settings ability. + * + * @ticket 64605 + */ + public function test_core_update_settings_allows_administrators(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array( + 'general' => array( + 'blogname' => 'Admin Updated Title', + ), + ), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Tests that the update-settings ability successfully updates settings. + * + * @ticket 64605 + */ + public function test_core_update_settings_updates_settings_in_database(): void { + $original_title = get_option( 'blogname' ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array( + 'general' => array( + 'blogname' => 'Updated Site Title', + ), + ), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'Updated Site Title', get_option( 'blogname' ) ); + + // Restore original value. + update_option( 'blogname', $original_title ); + } + + /** + * Tests that the update-settings ability returns grouped structure matching input. + * + * @ticket 64605 + */ + public function test_core_update_settings_returns_grouped_structure(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array( + 'general' => array( + 'blogname' => 'Test Title', + 'blogdescription' => 'Test Description', + ), + ), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'updated_settings', $data ); + $this->assertArrayHasKey( 'validation_errors', $data ); + $this->assertArrayHasKey( 'general', $data['updated_settings'] ); + $this->assertArrayHasKey( 'blogname', $data['updated_settings']['general'] ); + $this->assertArrayHasKey( 'blogdescription', $data['updated_settings']['general'] ); + $this->assertSame( 'Test Title', $data['updated_settings']['general']['blogname'] ); + $this->assertSame( 'Test Description', $data['updated_settings']['general']['blogdescription'] ); + } + + /** + * Tests that the update-settings ability can update multiple groups. + * + * @ticket 64605 + */ + public function test_core_update_settings_updates_multiple_groups(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array( + 'general' => array( + 'blogname' => 'Multi Group Test', + ), + 'reading' => array( + 'posts_per_page' => 15, + ), + ), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'general', $data['updated_settings'] ); + $this->assertArrayHasKey( 'reading', $data['updated_settings'] ); + $this->assertSame( 'Multi Group Test', $data['updated_settings']['general']['blogname'] ); + $this->assertSame( 15, $data['updated_settings']['reading']['posts_per_page'] ); + } + + /** + * Tests that the update-settings ability rejects settings without show_in_abilities. + * + * @ticket 64605 + */ + public function test_core_update_settings_rejects_settings_without_show_in_abilities(): void { + register_setting( + 'general', + 'test_setting_not_allowed', + array( + 'type' => 'string', + 'default' => 'default_value', + 'show_in_abilities' => false, + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array( + 'general' => array( + 'test_setting_not_allowed' => 'new_value', + ), + ), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEmpty( (array) $data['updated_settings'] ); + $this->assertArrayHasKey( 'general', $data['validation_errors'] ); + $this->assertArrayHasKey( 'test_setting_not_allowed', $data['validation_errors']['general'] ); + + unregister_setting( 'general', 'test_setting_not_allowed' ); + } + + /** + * Tests that the update-settings ability rejects settings in wrong group. + * + * @ticket 64605 + */ + public function test_core_update_settings_rejects_settings_in_wrong_group(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array( + 'reading' => array( + 'blogname' => 'Wrong Group Test', // blogname belongs to 'general', not 'reading'. + ), + ), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEmpty( (array) $data['updated_settings'] ); + $this->assertArrayHasKey( 'reading', $data['validation_errors'] ); + $this->assertArrayHasKey( 'blogname', $data['validation_errors']['reading'] ); + } + + /** + * Tests that the update-settings ability requires POST method. + * + * @ticket 64605 + */ + public function test_core_update_settings_requires_post_method(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 405, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 'rest_ability_invalid_method', $data['code'] ); + } + + /** + * Tests that the update-settings ability casts boolean values correctly. + * + * @ticket 64605 + */ + public function test_core_update_settings_casts_boolean_values(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array( + 'writing' => array( + 'use_smilies' => false, + ), + ), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'writing', $data['updated_settings'] ); + $this->assertArrayHasKey( 'use_smilies', $data['updated_settings']['writing'] ); + $this->assertIsBool( $data['updated_settings']['writing']['use_smilies'] ); + $this->assertFalse( $data['updated_settings']['writing']['use_smilies'] ); + } + + /** + * Tests that the update-settings ability casts integer values correctly. + * + * @ticket 64605 + */ + public function test_core_update_settings_casts_integer_values(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array( + 'reading' => array( + 'posts_per_page' => 25, + ), + ), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertArrayHasKey( 'reading', $data['updated_settings'] ); + $this->assertArrayHasKey( 'posts_per_page', $data['updated_settings']['reading'] ); + $this->assertIsInt( $data['updated_settings']['reading']['posts_per_page'] ); + $this->assertSame( 25, $data['updated_settings']['reading']['posts_per_page'] ); + } + + /** + * Tests that the update-settings ability handles partial success. + * + * @ticket 64605 + */ + public function test_core_update_settings_handles_partial_success(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array( + 'general' => array( + 'blogname' => 'Partial Success Test', + 'unknown_setting' => 'should_fail', // Not allowed. + ), + ), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + // blogname should be updated. + $this->assertArrayHasKey( 'general', $data['updated_settings'] ); + $this->assertArrayHasKey( 'blogname', $data['updated_settings']['general'] ); + $this->assertSame( 'Partial Success Test', $data['updated_settings']['general']['blogname'] ); + + // unknown_setting should have an error. + $this->assertArrayHasKey( 'general', $data['validation_errors'] ); + $this->assertArrayHasKey( 'unknown_setting', $data['validation_errors']['general'] ); + } + + /** + * Tests that the update-settings ability returns empty objects when no settings provided. + * + * @ticket 64605 + */ + public function test_core_update_settings_returns_empty_when_no_settings(): void { + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => array(), + ), + ) + ) + ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertEmpty( (array) $data['updated_settings'] ); + $this->assertEmpty( (array) $data['validation_errors'] ); + } + + /** + * Tests the symmetry between get-settings and update-settings. + * + * @ticket 64605 + */ + public function test_core_update_settings_symmetry_with_get_settings(): void { + // First, get settings. + $get_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); + $get_request->set_query_params( + array( + 'input' => array( + 'slugs' => array( 'blogname', 'blogdescription' ), + ), + ) + ); + $get_response = $this->server->dispatch( $get_request ); + $settings = $get_response->get_data(); + + // Modify the settings. + $settings['general']['blogname'] = 'Symmetry Test Title'; + $settings['general']['blogdescription'] = 'Symmetry Test Description'; + + // Update settings with the same structure. + $update_request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); + $update_request->set_header( 'Content-Type', 'application/json' ); + $update_request->set_body( + wp_json_encode( + array( + 'input' => array( + 'settings' => $settings, + ), + ) + ) + ); + $update_response = $this->server->dispatch( $update_request ); + + $this->assertSame( 200, $update_response->get_status() ); + + $data = $update_response->get_data(); + + $this->assertSame( 'Symmetry Test Title', $data['updated_settings']['general']['blogname'] ); + $this->assertSame( 'Symmetry Test Description', $data['updated_settings']['general']['blogdescription'] ); + + // Verify the values are actually in the database. + $this->assertSame( 'Symmetry Test Title', get_option( 'blogname' ) ); + $this->assertSame( 'Symmetry Test Description', get_option( 'blogdescription' ) ); + } } From d95b0c98712a697b2bafa68d2fbd77cec378c314 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 18:03:44 +0000 Subject: [PATCH 08/33] fix notices --- .../tests/rest-api/wpRestAbilitiesSettingsController.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 533dccc0baef5..a966d7c0e8095 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -45,6 +45,14 @@ public static function set_up_before_class(): void { // Register initial settings first so abilities can build schemas. register_initial_settings(); + // Unregister any existing abilities/categories to avoid "already registered" notices. + 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() ); + } + // Ensure core 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 ); From ad7ec2a776ad1b727d33af507e6c5dcdb7a773e7 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 18:21:03 +0000 Subject: [PATCH 09/33] update ticket --- .../wpRestAbilitiesSettingsController.php | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index a966d7c0e8095..78df95fca13ba 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -450,7 +450,7 @@ public function test_core_get_settings_returns_error_for_invalid_enum_value(): v /** * Tests that unauthenticated users cannot access the update-settings ability. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_requires_authentication(): void { wp_set_current_user( 0 ); @@ -478,7 +478,7 @@ public function test_core_update_settings_requires_authentication(): void { /** * Tests that subscribers cannot access the update-settings ability. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_requires_manage_options_capability(): void { wp_set_current_user( self::$subscriber_id ); @@ -506,7 +506,7 @@ public function test_core_update_settings_requires_manage_options_capability(): /** * Tests that administrators can access the update-settings ability. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_allows_administrators(): void { $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); @@ -532,7 +532,7 @@ public function test_core_update_settings_allows_administrators(): void { /** * Tests that the update-settings ability successfully updates settings. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_updates_settings_in_database(): void { $original_title = get_option( 'blogname' ); @@ -564,7 +564,7 @@ public function test_core_update_settings_updates_settings_in_database(): void { /** * Tests that the update-settings ability returns grouped structure matching input. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_returns_grouped_structure(): void { $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); @@ -601,7 +601,7 @@ public function test_core_update_settings_returns_grouped_structure(): void { /** * Tests that the update-settings ability can update multiple groups. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_updates_multiple_groups(): void { $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); @@ -637,7 +637,7 @@ public function test_core_update_settings_updates_multiple_groups(): void { /** * Tests that the update-settings ability rejects settings without show_in_abilities. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_rejects_settings_without_show_in_abilities(): void { register_setting( @@ -681,7 +681,7 @@ public function test_core_update_settings_rejects_settings_without_show_in_abili /** * Tests that the update-settings ability rejects settings in wrong group. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_rejects_settings_in_wrong_group(): void { $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); @@ -713,7 +713,7 @@ public function test_core_update_settings_rejects_settings_in_wrong_group(): voi /** * Tests that the update-settings ability requires POST method. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_requires_post_method(): void { $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/update-settings/run' ); @@ -728,7 +728,7 @@ public function test_core_update_settings_requires_post_method(): void { /** * Tests that the update-settings ability casts boolean values correctly. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_casts_boolean_values(): void { $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); @@ -761,7 +761,7 @@ public function test_core_update_settings_casts_boolean_values(): void { /** * Tests that the update-settings ability casts integer values correctly. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_casts_integer_values(): void { $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); @@ -794,7 +794,7 @@ public function test_core_update_settings_casts_integer_values(): void { /** * Tests that the update-settings ability handles partial success. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_handles_partial_success(): void { $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); @@ -832,7 +832,7 @@ public function test_core_update_settings_handles_partial_success(): void { /** * Tests that the update-settings ability returns empty objects when no settings provided. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_returns_empty_when_no_settings(): void { $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); @@ -859,7 +859,7 @@ public function test_core_update_settings_returns_empty_when_no_settings(): void /** * Tests the symmetry between get-settings and update-settings. * - * @ticket 64605 + * @ticket 64616 */ public function test_core_update_settings_symmetry_with_get_settings(): void { // First, get settings. From bd89f7bbb42e2f2615e2899628f239963a801278 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 20:43:23 +0000 Subject: [PATCH 10/33] lint fixes --- .../abilities/class-wp-settings-abilities.php | 4 ++-- src/wp-includes/option.php | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 8630673c355fd..8426da633e2ba 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -404,8 +404,8 @@ public static function execute_update_settings( $input = array() ): array { ); } - $grouped_settings = $input['settings']; - $allowed_settings = self::get_allowed_settings(); + $grouped_settings = $input['settings']; + $allowed_settings = self::get_allowed_settings(); $updated_settings = array(); $validation_errors = array(); diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index fbd650785c208..957b08ae082ec 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -3026,13 +3026,13 @@ function register_setting( $option_group, $option_name, $args = array() ) { $GLOBALS['new_whitelist_options'] = &$new_allowed_options; $defaults = array( - 'type' => 'string', - 'group' => $option_group, - 'label' => '', - 'description' => '', - 'sanitize_callback' => null, - 'show_in_rest' => false, - 'show_in_abilities' => false, + 'type' => 'string', + 'group' => $option_group, + 'label' => '', + 'description' => '', + 'sanitize_callback' => null, + 'show_in_rest' => false, + 'show_in_abilities' => false, ); // Back-compat: old sanitize callback is added. From 5f4e5ba47253b4e0c7bcad29da7c832563733756 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 20:44:27 +0000 Subject: [PATCH 11/33] don't include empty validation errors --- .../abilities/class-wp-settings-abilities.php | 14 +++++++++----- .../rest-api/wpRestAbilitiesSettingsController.php | 8 ++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 8426da633e2ba..a4e8139d80233 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -399,8 +399,7 @@ public static function execute_update_settings( $input = array() ): array { if ( empty( $input['settings'] ) || ! is_array( $input['settings'] ) ) { return array( - 'updated_settings' => (object) array(), - 'validation_errors' => (object) array(), + 'updated_settings' => (object) array(), ); } @@ -493,10 +492,15 @@ public static function execute_update_settings( $input = array() ): array { } } - return array( - 'updated_settings' => ! empty( $updated_settings ) ? $updated_settings : (object) array(), - 'validation_errors' => ! empty( $validation_errors ) ? $validation_errors : (object) array(), + $result = array( + 'updated_settings' => ! empty( $updated_settings ) ? $updated_settings : (object) array(), ); + + if ( ! empty( $validation_errors ) ) { + $result['validation_errors'] = $validation_errors; + } + + return $result; } /** diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 78df95fca13ba..5db5f84090f53 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -590,7 +590,7 @@ public function test_core_update_settings_returns_grouped_structure(): void { $data = $response->get_data(); $this->assertArrayHasKey( 'updated_settings', $data ); - $this->assertArrayHasKey( 'validation_errors', $data ); + $this->assertArrayNotHasKey( 'validation_errors', $data ); $this->assertArrayHasKey( 'general', $data['updated_settings'] ); $this->assertArrayHasKey( 'blogname', $data['updated_settings']['general'] ); $this->assertArrayHasKey( 'blogdescription', $data['updated_settings']['general'] ); @@ -805,8 +805,8 @@ public function test_core_update_settings_handles_partial_success(): void { 'input' => array( 'settings' => array( 'general' => array( - 'blogname' => 'Partial Success Test', - 'unknown_setting' => 'should_fail', // Not allowed. + 'blogname' => 'Partial Success Test', + 'unknown_setting' => 'should_fail', // Not allowed. ), ), ), @@ -853,7 +853,7 @@ public function test_core_update_settings_returns_empty_when_no_settings(): void $data = $response->get_data(); $this->assertEmpty( (array) $data['updated_settings'] ); - $this->assertEmpty( (array) $data['validation_errors'] ); + $this->assertArrayNotHasKey( 'validation_errors', $data ); } /** From 93a5238d54c65380ba6ff3a58aa3fd58034644f0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 21:04:13 +0000 Subject: [PATCH 12/33] fix variable naming --- .../abilities/class-wp-settings-abilities.php | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index a4e8139d80233..f2ea726cbb45b 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -32,12 +32,12 @@ class WP_Settings_Abilities { private static $available_groups; /** - * Dynamic output schema built from registered settings. + * Schema for settings grouped by registration group. * * @since 7.0.0 * @var array */ - private static $output_schema; + private static $settings_schema; /** * Available setting slugs with show_in_abilities enabled. @@ -70,7 +70,7 @@ public static function register(): void { private static function init(): void { self::$available_groups = self::get_available_groups(); self::$available_slugs = self::get_available_slugs(); - self::$output_schema = self::build_output_schema(); + self::$settings_schema = self::build_settings_schema(); } /** @@ -134,7 +134,7 @@ private static function get_available_slugs(): array { } /** - * Builds a rich output schema from registered settings metadata. + * Builds a schema for settings grouped by registration group. * * Creates a JSON Schema that documents each setting group and its settings * with their types, titles, descriptions, defaults, and any additional @@ -142,9 +142,9 @@ private static function get_available_slugs(): array { * * @since 7.0.0 * - * @return array JSON Schema for the output. + * @return array JSON Schema for settings. */ - private static function build_output_schema(): array { + private static function build_settings_schema(): array { $group_properties = array(); foreach ( self::get_allowed_settings() as $option_name => $args ) { @@ -244,7 +244,7 @@ private static function register_get_settings(): void { ), ), ), - 'output_schema' => self::$output_schema, + 'output_schema' => self::$settings_schema, 'execute_callback' => array( __CLASS__, 'execute_get_settings' ), 'permission_callback' => array( __CLASS__, 'check_manage_options' ), 'meta' => array( @@ -267,6 +267,10 @@ private static function register_get_settings(): void { * @return void */ private static function register_update_settings(): void { + // Reuse settings schema with updated description for input. + $input_settings_schema = self::$settings_schema; + $input_settings_schema['description'] = __( 'Settings to update, grouped by registration group. Same structure as returned by core/get-settings.' ); + wp_register_ability( 'core/update-settings', array( @@ -277,11 +281,7 @@ private static function register_update_settings(): void { 'type' => 'object', 'required' => array( 'settings' ), 'properties' => array( - 'settings' => array( - 'type' => 'object', - 'description' => __( 'Settings to update, grouped by registration group. Same structure as returned by core/get-settings.' ), - 'additionalProperties' => true, - ), + 'settings' => $input_settings_schema, ), 'additionalProperties' => false, ), From 70807434d7733c657badb1be2ff50410712efdc8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 21:04:20 +0000 Subject: [PATCH 13/33] update tests --- .../wpRestAbilitiesSettingsController.php | 114 ------------------ 1 file changed, 114 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 5db5f84090f53..ce56954ed6698 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -634,82 +634,6 @@ public function test_core_update_settings_updates_multiple_groups(): void { $this->assertSame( 15, $data['updated_settings']['reading']['posts_per_page'] ); } - /** - * Tests that the update-settings ability rejects settings without show_in_abilities. - * - * @ticket 64616 - */ - public function test_core_update_settings_rejects_settings_without_show_in_abilities(): void { - register_setting( - 'general', - 'test_setting_not_allowed', - array( - 'type' => 'string', - 'default' => 'default_value', - 'show_in_abilities' => false, - ) - ); - - $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); - $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( - wp_json_encode( - array( - 'input' => array( - 'settings' => array( - 'general' => array( - 'test_setting_not_allowed' => 'new_value', - ), - ), - ), - ) - ) - ); - $response = $this->server->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - - $this->assertEmpty( (array) $data['updated_settings'] ); - $this->assertArrayHasKey( 'general', $data['validation_errors'] ); - $this->assertArrayHasKey( 'test_setting_not_allowed', $data['validation_errors']['general'] ); - - unregister_setting( 'general', 'test_setting_not_allowed' ); - } - - /** - * Tests that the update-settings ability rejects settings in wrong group. - * - * @ticket 64616 - */ - public function test_core_update_settings_rejects_settings_in_wrong_group(): void { - $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); - $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( - wp_json_encode( - array( - 'input' => array( - 'settings' => array( - 'reading' => array( - 'blogname' => 'Wrong Group Test', // blogname belongs to 'general', not 'reading'. - ), - ), - ), - ) - ) - ); - $response = $this->server->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - - $this->assertEmpty( (array) $data['updated_settings'] ); - $this->assertArrayHasKey( 'reading', $data['validation_errors'] ); - $this->assertArrayHasKey( 'blogname', $data['validation_errors']['reading'] ); - } - /** * Tests that the update-settings ability requires POST method. * @@ -791,44 +715,6 @@ public function test_core_update_settings_casts_integer_values(): void { $this->assertSame( 25, $data['updated_settings']['reading']['posts_per_page'] ); } - /** - * Tests that the update-settings ability handles partial success. - * - * @ticket 64616 - */ - public function test_core_update_settings_handles_partial_success(): void { - $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); - $request->set_header( 'Content-Type', 'application/json' ); - $request->set_body( - wp_json_encode( - array( - 'input' => array( - 'settings' => array( - 'general' => array( - 'blogname' => 'Partial Success Test', - 'unknown_setting' => 'should_fail', // Not allowed. - ), - ), - ), - ) - ) - ); - $response = $this->server->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - - $data = $response->get_data(); - - // blogname should be updated. - $this->assertArrayHasKey( 'general', $data['updated_settings'] ); - $this->assertArrayHasKey( 'blogname', $data['updated_settings']['general'] ); - $this->assertSame( 'Partial Success Test', $data['updated_settings']['general']['blogname'] ); - - // unknown_setting should have an error. - $this->assertArrayHasKey( 'general', $data['validation_errors'] ); - $this->assertArrayHasKey( 'unknown_setting', $data['validation_errors']['general'] ); - } - /** * Tests that the update-settings ability returns empty objects when no settings provided. * From 022e463cf65b189a341722bd7b6fcd6c6bc23959 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 21:15:22 +0000 Subject: [PATCH 14/33] remove explicit validaiton --- .../abilities/class-wp-settings-abilities.php | 81 +++++-------------- 1 file changed, 22 insertions(+), 59 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index f2ea726cbb45b..1b27f6eb2741b 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -267,10 +267,13 @@ private static function register_get_settings(): void { * @return void */ private static function register_update_settings(): void { - // Reuse settings schema with updated description for input. + // Reuse settings schema with updated descriptions for input and output. $input_settings_schema = self::$settings_schema; $input_settings_schema['description'] = __( 'Settings to update, grouped by registration group. Same structure as returned by core/get-settings.' ); + $output_settings_schema = self::$settings_schema; + $output_settings_schema['description'] = __( 'Settings that were successfully updated, grouped by registration group.' ); + wp_register_ability( 'core/update-settings', array( @@ -288,14 +291,7 @@ private static function register_update_settings(): void { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'updated_settings' => array( - 'type' => 'object', - 'description' => __( 'Settings that were successfully updated, grouped by registration group. Same shape as input.' ), - ), - 'validation_errors' => array( - 'type' => 'object', - 'description' => __( 'Validation errors grouped by registration group. Same shape as input but with error messages as values.' ), - ), + 'updated_settings' => $output_settings_schema, ), 'additionalProperties' => false, ), @@ -379,8 +375,7 @@ public static function execute_get_settings( $input = array() ): array { * Execute callback for core/update-settings ability. * * Updates registered settings that are exposed through the Abilities API. - * Returns updated settings and validation errors in a grouped structure - * matching the input format. + * Returns updated settings grouped by registration group. * * @since 7.0.0 * @@ -389,12 +384,9 @@ public static function execute_get_settings( $input = array() ): array { * * @type array $settings Settings to update, grouped by registration group. * } - * @return array { - * @type array $updated_settings Settings that were successfully updated, grouped by registration group. - * @type array $validation_errors Validation errors grouped by registration group. - * } + * @return array|WP_Error Updated settings on success, WP_Error on failure. */ - public static function execute_update_settings( $input = array() ): array { + public static function execute_update_settings( $input = array() ) { $input = is_array( $input ) ? $input : array(); if ( empty( $input['settings'] ) || ! is_array( $input['settings'] ) ) { @@ -406,48 +398,30 @@ public static function execute_update_settings( $input = array() ): array { $grouped_settings = $input['settings']; $allowed_settings = self::get_allowed_settings(); - $updated_settings = array(); - $validation_errors = array(); + $updated_settings = array(); // Iterate through groups (general, reading, writing, etc.). foreach ( $grouped_settings as $group => $settings ) { if ( ! is_array( $settings ) ) { - if ( ! isset( $validation_errors[ $group ] ) ) { - $validation_errors[ $group ] = array(); - } - $validation_errors[ $group ]['_error'] = __( 'Group settings must be an object.' ); continue; } // Iterate through settings within each group. foreach ( $settings as $option_name => $value ) { - // Check if setting is allowed. + // Skip settings that are not allowed (handled by schema validation). if ( ! isset( $allowed_settings[ $option_name ] ) ) { - if ( ! isset( $validation_errors[ $group ] ) ) { - $validation_errors[ $group ] = array(); - } - $validation_errors[ $group ][ $option_name ] = __( 'Setting is not allowed to be modified.' ); continue; } $args = $allowed_settings[ $option_name ]; - // Verify setting belongs to the specified group. + // Skip settings in wrong group (handled by schema validation). $setting_group = $args['group'] ?? 'general'; if ( $setting_group !== $group ) { - if ( ! isset( $validation_errors[ $group ] ) ) { - $validation_errors[ $group ] = array(); - } - $validation_errors[ $group ][ $option_name ] = sprintf( - /* translators: 1: Expected group, 2: Provided group. */ - __( 'Setting belongs to group "%1$s", not "%2$s".' ), - $setting_group, - $group - ); continue; } - // Build schema for validation. + // Build schema for sanitization. $schema = array( 'type' => $args['type'] ?? 'string', ); @@ -455,16 +429,6 @@ public static function execute_update_settings( $input = array() ): array { $schema = array_merge( $schema, $args['show_in_rest']['schema'] ); } - // Validate value against schema. - $validation = rest_validate_value_from_schema( $value, $schema, $option_name ); - if ( is_wp_error( $validation ) ) { - if ( ! isset( $validation_errors[ $group ] ) ) { - $validation_errors[ $group ] = array(); - } - $validation_errors[ $group ][ $option_name ] = $validation->get_error_message(); - continue; - } - // Sanitize the value. $sanitized_value = rest_sanitize_value_from_schema( $value, $schema ); @@ -484,23 +448,22 @@ public static function execute_update_settings( $input = array() ): array { $args['type'] ?? 'string' ); } else { - if ( ! isset( $validation_errors[ $group ] ) ) { - $validation_errors[ $group ] = array(); - } - $validation_errors[ $group ][ $option_name ] = __( 'Failed to update setting.' ); + return new WP_Error( + 'rest_setting_update_failed', + sprintf( + /* translators: %s: Option name. */ + __( 'Failed to update setting: %s.' ), + $option_name + ), + array( 'status' => 500 ) + ); } } } - $result = array( + return array( 'updated_settings' => ! empty( $updated_settings ) ? $updated_settings : (object) array(), ); - - if ( ! empty( $validation_errors ) ) { - $result['validation_errors'] = $validation_errors; - } - - return $result; } /** From 4ed8138e6c615c02b6a8632a76c9e678e5e9de56 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 9 Feb 2026 21:31:49 +0000 Subject: [PATCH 15/33] fix symetry issue --- .../abilities/class-wp-settings-abilities.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 1b27f6eb2741b..3742080975ad0 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -437,16 +437,18 @@ public static function execute_update_settings( $input = array() ) { $sanitized_value = call_user_func( $args['sanitize_callback'], $sanitized_value ); } - $updated = update_option( $option_name, $sanitized_value ); - if ( $updated || get_option( $option_name ) === $sanitized_value ) { + $setting_type = $args['type'] ?? 'string'; + $updated = update_option( $option_name, $sanitized_value ); + + // Cast current value for comparison (handles type mismatches from database). + $current_value = self::cast_value( get_option( $option_name ), $setting_type ); + + if ( $updated || $current_value === $sanitized_value ) { // Add to updated_settings with the same grouped structure. if ( ! isset( $updated_settings[ $group ] ) ) { $updated_settings[ $group ] = array(); } - $updated_settings[ $group ][ $option_name ] = self::cast_value( - get_option( $option_name ), - $args['type'] ?? 'string' - ); + $updated_settings[ $group ][ $option_name ] = $current_value; } else { return new WP_Error( 'rest_setting_update_failed', From 00fc880fb616d4f61c0b1d80a5afe57eeaa91aa8 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 10:42:27 +0000 Subject: [PATCH 16/33] Update src/wp-includes/abilities/class-wp-settings-abilities.php Co-authored-by: Weston Ruter --- src/wp-includes/abilities/class-wp-settings-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 3742080975ad0..172f8db45ad3a 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -37,7 +37,7 @@ class WP_Settings_Abilities { * @since 7.0.0 * @var array */ - private static $settings_schema; + private static array $settings_schema; /** * Available setting slugs with show_in_abilities enabled. From bfd643adbf413a3d0d1dcdfaa2e237530f9c8863 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 10:42:33 +0000 Subject: [PATCH 17/33] Update src/wp-includes/abilities/class-wp-settings-abilities.php Co-authored-by: Weston Ruter --- src/wp-includes/abilities/class-wp-settings-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 172f8db45ad3a..2eb79ecb13dde 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -142,7 +142,7 @@ private static function get_available_slugs(): array { * * @since 7.0.0 * - * @return array JSON Schema for settings. + * @return array JSON Schema for settings. */ private static function build_settings_schema(): array { $group_properties = array(); From af4497e5a2ce1bdd050f94daafd435fb6086d6af Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 10:42:40 +0000 Subject: [PATCH 18/33] Update src/wp-includes/abilities/class-wp-settings-abilities.php Co-authored-by: Weston Ruter --- src/wp-includes/abilities/class-wp-settings-abilities.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 2eb79ecb13dde..c4d15516ef576 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -263,8 +263,6 @@ private static function register_get_settings(): void { * Registers the core/update-settings ability. * * @since 7.0.0 - * - * @return void */ private static function register_update_settings(): void { // Reuse settings schema with updated descriptions for input and output. From f7257897355d85dc29e138e6f14c646616fba497 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 10:42:47 +0000 Subject: [PATCH 19/33] Update src/wp-includes/abilities/class-wp-settings-abilities.php Co-authored-by: Weston Ruter --- src/wp-includes/abilities/class-wp-settings-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index c4d15516ef576..18cf36b1709d2 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -382,7 +382,7 @@ public static function execute_get_settings( $input = array() ): array { * * @type array $settings Settings to update, grouped by registration group. * } - * @return array|WP_Error Updated settings on success, WP_Error on failure. + * @return array|object>|WP_Error Updated settings on success, WP_Error on failure. */ public static function execute_update_settings( $input = array() ) { $input = is_array( $input ) ? $input : array(); From b29022ba79fade8f33ffd164b427b239d17332e4 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 10:43:35 +0000 Subject: [PATCH 20/33] Update src/wp-includes/abilities/class-wp-settings-abilities.php Co-authored-by: Weston Ruter --- src/wp-includes/abilities/class-wp-settings-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 18cf36b1709d2..d101a98b2dec7 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -45,7 +45,7 @@ class WP_Settings_Abilities { * @since 7.0.0 * @var string[] */ - private static $available_slugs; + private static array $available_slugs; /** * Registers all settings abilities. From e9f11bbeaf7a11d40b46bb6d3064f68b91ed4b5d Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 10:47:22 +0000 Subject: [PATCH 21/33] Update src/wp-includes/abilities/class-wp-settings-abilities.php Co-authored-by: Weston Ruter --- src/wp-includes/abilities/class-wp-settings-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index d101a98b2dec7..5d0cadfc6de2e 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -35,7 +35,7 @@ class WP_Settings_Abilities { * Schema for settings grouped by registration group. * * @since 7.0.0 - * @var array + * @var array */ private static array $settings_schema; From 102331bc29519c2b229b9b5a638f95acae4900fe Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 11:19:59 +0000 Subject: [PATCH 22/33] simplify get available slugs --- .../abilities/class-wp-settings-abilities.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 5d0cadfc6de2e..28bde0aa9dac2 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -122,15 +122,7 @@ private static function get_available_groups(): array { * @return string[] List of unique setting slugs. */ private static function get_available_slugs(): array { - $slugs = array(); - - foreach ( self::get_allowed_settings() as $option_name => $args ) { - $slugs[] = $option_name; - } - - sort( $slugs ); - - return $slugs; + return sort( array_keys( self::get_allowed_settings() ) ); } /** From d87cbfa80473f95eb809b41f64534ca1d2f230c0 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 11:20:16 +0000 Subject: [PATCH 23/33] add type anotattion --- src/wp-includes/abilities/class-wp-settings-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 28bde0aa9dac2..e2832595cffd6 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -29,7 +29,7 @@ class WP_Settings_Abilities { * @since 7.0.0 * @var string[] */ - private static $available_groups; + private static array $available_groups; /** * Schema for settings grouped by registration group. From bcda1e8d323d83dcebc18acdf45b38e7dc8f540b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 11:20:31 +0000 Subject: [PATCH 24/33] remove unnessary test --- .../tests/rest-api/wpRestAbilitiesSettingsController.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index ce56954ed6698..b68308ed0f047 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -590,7 +590,6 @@ public function test_core_update_settings_returns_grouped_structure(): void { $data = $response->get_data(); $this->assertArrayHasKey( 'updated_settings', $data ); - $this->assertArrayNotHasKey( 'validation_errors', $data ); $this->assertArrayHasKey( 'general', $data['updated_settings'] ); $this->assertArrayHasKey( 'blogname', $data['updated_settings']['general'] ); $this->assertArrayHasKey( 'blogdescription', $data['updated_settings']['general'] ); @@ -739,7 +738,6 @@ public function test_core_update_settings_returns_empty_when_no_settings(): void $data = $response->get_data(); $this->assertEmpty( (array) $data['updated_settings'] ); - $this->assertArrayNotHasKey( 'validation_errors', $data ); } /** From 0758e59927b9910b9ede35e7b265f7e53f490839 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 11:24:10 +0000 Subject: [PATCH 25/33] fix get available slugs --- src/wp-includes/abilities/class-wp-settings-abilities.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index e2832595cffd6..dcbdfbd309563 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -122,7 +122,9 @@ private static function get_available_groups(): array { * @return string[] List of unique setting slugs. */ private static function get_available_slugs(): array { - return sort( array_keys( self::get_allowed_settings() ) ); + $slugs = array_keys( self::get_allowed_settings() ); + sort( $slugs ); + return $slugs; } /** From ca3b47ebf9b686615e480122a23e36fc5dd55103 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 11:27:06 +0000 Subject: [PATCH 26/33] properly cast value before comparing --- src/wp-includes/abilities/class-wp-settings-abilities.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index dcbdfbd309563..b70c9d1462581 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -432,8 +432,9 @@ public static function execute_update_settings( $input = array() ) { $setting_type = $args['type'] ?? 'string'; $updated = update_option( $option_name, $sanitized_value ); - // Cast current value for comparison (handles type mismatches from database). - $current_value = self::cast_value( get_option( $option_name ), $setting_type ); + // Cast values for comparison (handles type mismatches from database and REST sanitization). + $current_value = self::cast_value( get_option( $option_name ), $setting_type ); + $sanitized_value = self::cast_value( $sanitized_value, $setting_type ); if ( $updated || $current_value === $sanitized_value ) { // Add to updated_settings with the same grouped structure. From 1bd5e108dcce52ff79e79df79716e50089a833c9 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 11:28:10 +0000 Subject: [PATCH 27/33] explicit test clean up --- .../wpRestAbilitiesSettingsController.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index b68308ed0f047..bfd6987aa3b23 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -343,6 +343,8 @@ public function test_core_get_settings_requires_get_method(): void { * @ticket 64605 */ public function test_core_get_settings_returns_correct_values(): void { + $original_blogname = get_option( 'blogname' ); + update_option( 'blogname', 'Test Site Name' ); $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); @@ -360,6 +362,9 @@ public function test_core_get_settings_returns_correct_values(): void { $data = $response->get_data(); $this->assertSame( 'Test Site Name', $data['general']['blogname'] ); + + // Restore original value. + update_option( 'blogname', $original_blogname ); } /** @@ -509,6 +514,8 @@ public function test_core_update_settings_requires_manage_options_capability(): * @ticket 64616 */ public function test_core_update_settings_allows_administrators(): void { + $original_blogname = get_option( 'blogname' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( @@ -527,6 +534,9 @@ public function test_core_update_settings_allows_administrators(): void { $response = $this->server->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); + + // Restore original value. + update_option( 'blogname', $original_blogname ); } /** @@ -567,6 +577,9 @@ public function test_core_update_settings_updates_settings_in_database(): void { * @ticket 64616 */ public function test_core_update_settings_returns_grouped_structure(): void { + $original_blogname = get_option( 'blogname' ); + $original_blogdescription = get_option( 'blogdescription' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( @@ -595,6 +608,10 @@ public function test_core_update_settings_returns_grouped_structure(): void { $this->assertArrayHasKey( 'blogdescription', $data['updated_settings']['general'] ); $this->assertSame( 'Test Title', $data['updated_settings']['general']['blogname'] ); $this->assertSame( 'Test Description', $data['updated_settings']['general']['blogdescription'] ); + + // Restore original values. + update_option( 'blogname', $original_blogname ); + update_option( 'blogdescription', $original_blogdescription ); } /** @@ -603,6 +620,9 @@ public function test_core_update_settings_returns_grouped_structure(): void { * @ticket 64616 */ public function test_core_update_settings_updates_multiple_groups(): void { + $original_blogname = get_option( 'blogname' ); + $original_posts_per_page = get_option( 'posts_per_page' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( @@ -631,6 +651,10 @@ public function test_core_update_settings_updates_multiple_groups(): void { $this->assertArrayHasKey( 'reading', $data['updated_settings'] ); $this->assertSame( 'Multi Group Test', $data['updated_settings']['general']['blogname'] ); $this->assertSame( 15, $data['updated_settings']['reading']['posts_per_page'] ); + + // Restore original values. + update_option( 'blogname', $original_blogname ); + update_option( 'posts_per_page', $original_posts_per_page ); } /** @@ -654,6 +678,8 @@ public function test_core_update_settings_requires_post_method(): void { * @ticket 64616 */ public function test_core_update_settings_casts_boolean_values(): void { + $original_use_smilies = get_option( 'use_smilies' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( @@ -679,6 +705,9 @@ public function test_core_update_settings_casts_boolean_values(): void { $this->assertArrayHasKey( 'use_smilies', $data['updated_settings']['writing'] ); $this->assertIsBool( $data['updated_settings']['writing']['use_smilies'] ); $this->assertFalse( $data['updated_settings']['writing']['use_smilies'] ); + + // Restore original value. + update_option( 'use_smilies', $original_use_smilies ); } /** @@ -687,6 +716,8 @@ public function test_core_update_settings_casts_boolean_values(): void { * @ticket 64616 */ public function test_core_update_settings_casts_integer_values(): void { + $original_posts_per_page = get_option( 'posts_per_page' ); + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/core/update-settings/run' ); $request->set_header( 'Content-Type', 'application/json' ); $request->set_body( @@ -712,6 +743,9 @@ public function test_core_update_settings_casts_integer_values(): void { $this->assertArrayHasKey( 'posts_per_page', $data['updated_settings']['reading'] ); $this->assertIsInt( $data['updated_settings']['reading']['posts_per_page'] ); $this->assertSame( 25, $data['updated_settings']['reading']['posts_per_page'] ); + + // Restore original value. + update_option( 'posts_per_page', $original_posts_per_page ); } /** @@ -746,6 +780,9 @@ public function test_core_update_settings_returns_empty_when_no_settings(): void * @ticket 64616 */ public function test_core_update_settings_symmetry_with_get_settings(): void { + $original_blogname = get_option( 'blogname' ); + $original_blogdescription = get_option( 'blogdescription' ); + // First, get settings. $get_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/core/get-settings/run' ); $get_request->set_query_params( @@ -786,5 +823,9 @@ public function test_core_update_settings_symmetry_with_get_settings(): void { // Verify the values are actually in the database. $this->assertSame( 'Symmetry Test Title', get_option( 'blogname' ) ); $this->assertSame( 'Symmetry Test Description', get_option( 'blogdescription' ) ); + + // Restore original values. + update_option( 'blogname', $original_blogname ); + update_option( 'blogdescription', $original_blogdescription ); } } From 8ee8c3835859edc34038d7da5fe1a15d11bde97a Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 11:44:31 +0000 Subject: [PATCH 28/33] minor enhacements --- .../abilities/class-wp-settings-abilities.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index b70c9d1462581..6171480cb922f 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -400,36 +400,33 @@ public static function execute_update_settings( $input = array() ) { // Iterate through settings within each group. foreach ( $settings as $option_name => $value ) { - // Skip settings that are not allowed (handled by schema validation). if ( ! isset( $allowed_settings[ $option_name ] ) ) { continue; } $args = $allowed_settings[ $option_name ]; - // Skip settings in wrong group (handled by schema validation). $setting_group = $args['group'] ?? 'general'; if ( $setting_group !== $group ) { continue; } - // Build schema for sanitization. + $setting_type = $args['type'] ?? 'string'; + $schema = array( - 'type' => $args['type'] ?? 'string', + 'type' => $setting_type, ); if ( is_array( $args['show_in_rest'] ) && isset( $args['show_in_rest']['schema'] ) ) { $schema = array_merge( $schema, $args['show_in_rest']['schema'] ); } - // Sanitize the value. $sanitized_value = rest_sanitize_value_from_schema( $value, $schema ); - // Apply registered sanitize callback if exists. if ( ! empty( $args['sanitize_callback'] ) && is_callable( $args['sanitize_callback'] ) ) { $sanitized_value = call_user_func( $args['sanitize_callback'], $sanitized_value ); } - $setting_type = $args['type'] ?? 'string'; + $updated = update_option( $option_name, $sanitized_value ); // Cast values for comparison (handles type mismatches from database and REST sanitization). @@ -437,7 +434,6 @@ public static function execute_update_settings( $input = array() ) { $sanitized_value = self::cast_value( $sanitized_value, $setting_type ); if ( $updated || $current_value === $sanitized_value ) { - // Add to updated_settings with the same grouped structure. if ( ! isset( $updated_settings[ $group ] ) ) { $updated_settings[ $group ] = array(); } From c2574253bd7c0ae1c01bc8166db766f402e3f1d4 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Feb 2026 11:51:05 +0000 Subject: [PATCH 29/33] lint fix --- src/wp-includes/abilities/class-wp-settings-abilities.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 6171480cb922f..efa992680140d 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -426,8 +426,7 @@ public static function execute_update_settings( $input = array() ) { $sanitized_value = call_user_func( $args['sanitize_callback'], $sanitized_value ); } - - $updated = update_option( $option_name, $sanitized_value ); + $updated = update_option( $option_name, $sanitized_value ); // Cast values for comparison (handles type mismatches from database and REST sanitization). $current_value = self::cast_value( get_option( $option_name ), $setting_type ); From f58ccf66f284aa5cbf34b889c843b66678863a93 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 13 Feb 2026 07:52:30 +0000 Subject: [PATCH 30/33] Update src/wp-includes/abilities/class-wp-settings-abilities.php Co-authored-by: Weston Ruter --- src/wp-includes/abilities/class-wp-settings-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index efa992680140d..1673ca03e944a 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -378,7 +378,7 @@ public static function execute_get_settings( $input = array() ): array { * } * @return array|object>|WP_Error Updated settings on success, WP_Error on failure. */ - public static function execute_update_settings( $input = array() ) { + public static function execute_update_settings( $input = array() ): array { $input = is_array( $input ) ? $input : array(); if ( empty( $input['settings'] ) || ! is_array( $input['settings'] ) ) { From ec9627e3f7808ac57f7ffe97a2a7a40d62508f7e Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Fri, 13 Feb 2026 07:52:36 +0000 Subject: [PATCH 31/33] Update src/wp-includes/abilities/class-wp-settings-abilities.php Co-authored-by: Weston Ruter --- src/wp-includes/abilities/class-wp-settings-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 1673ca03e944a..d10c70ab252ee 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -371,7 +371,7 @@ public static function execute_get_settings( $input = array() ): array { * * @since 7.0.0 * - * @param array $input { + * @param array $input { * Input parameters. * * @type array $settings Settings to update, grouped by registration group. From 13c9e86ee8a8023807afa88ff365ff08a5dd2988 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 16 Feb 2026 20:08:40 +0000 Subject: [PATCH 32/33] Update src/wp-includes/abilities/class-wp-settings-abilities.php Co-authored-by: Dovid Levine --- .../abilities/class-wp-settings-abilities.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index d10c70ab252ee..a46ce63daf077 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -432,12 +432,7 @@ public static function execute_update_settings( $input = array() ): array { $current_value = self::cast_value( get_option( $option_name ), $setting_type ); $sanitized_value = self::cast_value( $sanitized_value, $setting_type ); - if ( $updated || $current_value === $sanitized_value ) { - if ( ! isset( $updated_settings[ $group ] ) ) { - $updated_settings[ $group ] = array(); - } - $updated_settings[ $group ][ $option_name ] = $current_value; - } else { + if ( ! $updated && $current_value !== $sanitized_value ) { return new WP_Error( 'rest_setting_update_failed', sprintf( @@ -448,6 +443,12 @@ public static function execute_update_settings( $input = array() ): array { array( 'status' => 500 ) ); } + + if ( ! isset( $updated_settings[ $group ] ) ) { + $updated_settings[ $group ] = array(); + } + + $updated_settings[ $group ][ $option_name ] = $current_value; } } From d1297492fc4019e6bcd05efb5441838e66194758 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 17 Feb 2026 13:43:35 +0000 Subject: [PATCH 33/33] Update src/wp-includes/abilities/class-wp-settings-abilities.php Co-authored-by: Dovid Levine --- src/wp-includes/abilities/class-wp-settings-abilities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index a46ce63daf077..7a28f74589140 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -422,7 +422,7 @@ public static function execute_update_settings( $input = array() ): array { $sanitized_value = rest_sanitize_value_from_schema( $value, $schema ); - if ( ! empty( $args['sanitize_callback'] ) && is_callable( $args['sanitize_callback'] ) ) { + if ( isset( $args['sanitize_callback'] ) && is_callable( $args['sanitize_callback'] ) ) { $sanitized_value = call_user_func( $args['sanitize_callback'], $sanitized_value ); }