diff --git a/src/wp-includes/abilities/class-wp-settings-abilities.php b/src/wp-includes/abilities/class-wp-settings-abilities.php index 5af7fa48450ee..7a28f74589140 100644 --- a/src/wp-includes/abilities/class-wp-settings-abilities.php +++ b/src/wp-includes/abilities/class-wp-settings-abilities.php @@ -29,15 +29,15 @@ class WP_Settings_Abilities { * @since 7.0.0 * @var string[] */ - private static $available_groups; + private static array $available_groups; /** - * Dynamic output schema built from registered settings. + * Schema for settings grouped by registration group. * * @since 7.0.0 - * @var array + * @var array */ - private static $output_schema; + private static array $settings_schema; /** * Available setting slugs with show_in_abilities enabled. @@ -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. @@ -57,6 +57,7 @@ class WP_Settings_Abilities { public static function register(): void { self::init(); self::register_get_settings(); + self::register_update_settings(); } /** @@ -69,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(); } /** @@ -121,29 +122,23 @@ 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; - } - + $slugs = array_keys( self::get_allowed_settings() ); sort( $slugs ); - return $slugs; } /** - * 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 - * schema properties from show_in_rest. + * schema properties from show_in_abilities. * * @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 ) { @@ -163,6 +158,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', @@ -238,7 +238,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( @@ -253,6 +253,54 @@ private static function register_get_settings(): void { ); } + /** + * Registers the core/update-settings ability. + * + * @since 7.0.0 + */ + private static function register_update_settings(): void { + // 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( + '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' => $input_settings_schema, + ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'updated_settings' => $output_settings_schema, + ), + '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. * @@ -315,6 +363,100 @@ 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 grouped by registration group. + * + * @since 7.0.0 + * + * @param array $input { + * Input parameters. + * + * @type array $settings Settings to update, grouped by registration group. + * } + * @return array|object>|WP_Error Updated settings on success, WP_Error on failure. + */ + 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(), + ); + } + + $grouped_settings = $input['settings']; + $allowed_settings = self::get_allowed_settings(); + + $updated_settings = array(); + + // Iterate through groups (general, reading, writing, etc.). + foreach ( $grouped_settings as $group => $settings ) { + if ( ! is_array( $settings ) ) { + continue; + } + + // Iterate through settings within each group. + foreach ( $settings as $option_name => $value ) { + if ( ! isset( $allowed_settings[ $option_name ] ) ) { + continue; + } + + $args = $allowed_settings[ $option_name ]; + + $setting_group = $args['group'] ?? 'general'; + if ( $setting_group !== $group ) { + continue; + } + + $setting_type = $args['type'] ?? 'string'; + + $schema = array( + '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'] ); + } + + $sanitized_value = rest_sanitize_value_from_schema( $value, $schema ); + + if ( isset( $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 ); + + // 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 ) { + return new WP_Error( + 'rest_setting_update_failed', + sprintf( + /* translators: %s: Option name. */ + __( 'Failed to update setting: %s.' ), + $option_name + ), + array( 'status' => 500 ) + ); + } + + if ( ! isset( $updated_settings[ $group ] ) ) { + $updated_settings[ $group ] = array(); + } + + $updated_settings[ $group ][ $option_name ] = $current_value; + } + } + + return array( + 'updated_settings' => ! empty( $updated_settings ) ? $updated_settings : (object) array(), + ); + } + /** * Casts a value to the appropriate type based on the setting's registered type. * 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-includes/option.php b/src/wp-includes/option.php index 8a9a2c3c89ece..957b08ae082ec 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2768,17 +2768,18 @@ 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' => $uri_schema, ), - 'show_in_abilities' => true, 'type' => 'string', 'description' => __( 'Site URL.' ), ) @@ -2786,17 +2787,18 @@ 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' => $email_schema, ), - 'show_in_abilities' => true, 'type' => 'string', 'description' => __( 'This address is used for admin purposes, like new user notification.' ), ) @@ -2945,16 +2947,18 @@ 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' => $open_closed_enum_schema, ), - 'show_in_abilities' => true, 'type' => 'string', 'description' => __( 'Allow link notifications from other blogs (pingbacks and trackbacks) on new articles.' ), ) @@ -2965,11 +2969,11 @@ 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' => $open_closed_enum_schema, ), - 'show_in_abilities' => true, 'type' => 'string', 'label' => __( 'Allow comments on new posts' ), 'description' => __( 'Allow people to submit comments on new posts.' ), @@ -3006,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()`. * } */ @@ -3021,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. 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'] ); + } +} diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 198c0c3b8bc69..bfd6987aa3b23 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 ); @@ -335,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' ); @@ -352,5 +362,470 @@ 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 ); + } + + /** + * 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'] ); + } + + /** + * 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'] ); + } + + /** + * Tests that unauthenticated users cannot access the update-settings ability. + * + * @ticket 64616 + */ + 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 64616 + */ + 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 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( + 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() ); + + // Restore original value. + update_option( 'blogname', $original_blogname ); + } + + /** + * Tests that the update-settings ability successfully updates settings. + * + * @ticket 64616 + */ + 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 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( + 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( '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'] ); + + // Restore original values. + update_option( 'blogname', $original_blogname ); + update_option( 'blogdescription', $original_blogdescription ); + } + + /** + * Tests that the update-settings ability can update multiple groups. + * + * @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( + 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'] ); + + // Restore original values. + update_option( 'blogname', $original_blogname ); + update_option( 'posts_per_page', $original_posts_per_page ); + } + + /** + * Tests that the update-settings ability requires POST method. + * + * @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' ); + $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 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( + 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'] ); + + // Restore original value. + update_option( 'use_smilies', $original_use_smilies ); + } + + /** + * Tests that the update-settings ability casts integer values correctly. + * + * @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( + 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'] ); + + // Restore original value. + update_option( 'posts_per_page', $original_posts_per_page ); + } + + /** + * Tests that the update-settings ability returns empty objects when no settings provided. + * + * @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' ); + $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'] ); + } + + /** + * Tests the symmetry between get-settings and update-settings. + * + * @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( + 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' ) ); + + // Restore original values. + update_option( 'blogname', $original_blogname ); + update_option( 'blogdescription', $original_blogdescription ); } }