diff --git a/includes/Abilities/Settings/Settings.php b/includes/Abilities/Settings/Settings.php index a6be43b4..4eff0fe2 100644 --- a/includes/Abilities/Settings/Settings.php +++ b/includes/Abilities/Settings/Settings.php @@ -17,10 +17,10 @@ /** * Class - Settings * - * Registers the read-only `core/settings` ability, which returns WordPress settings as a - * flat map of setting name to value. Only settings flagged with `show_in_abilities` are - * exposed. It is structured to also back a future write-oriented `core/manage-settings` - * ability via the shared helpers (get_exposed_settings(), value_schema(), cast_value()). + * Registers the read-only `core/settings` ability and the write-oriented `core/manage-settings` + * ability. Both operate on the same settings — those flagged with `show_in_abilities` — as a flat + * map of setting name to value: `core/settings` reads them and `core/manage-settings` updates them, + * sharing the helpers get_exposed_settings(), value_schema() and cast_value(). * * This class is kept almost identical to the WordPress core class `WP_Settings_Abilities` * so the two implementations stay in sync. Differences from the core class are marked with @@ -74,13 +74,7 @@ public function init(): void { */ public function register(): void { $this->register_get_settings(); - - /* - * A future write-oriented ability can be registered here, reusing the shared - * helpers below (get_exposed_settings(), value_schema(), cast_value()): - * - * $this->register_manage_settings(); - */ + $this->register_manage_settings(); } /** @@ -136,6 +130,62 @@ private function register_get_settings(): void { ); } + /** + * Registers the write-oriented `core/manage-settings` ability. + * + * The input and output schemas reuse each exposed setting's own schema, so every setting + * readable via `core/settings` is also writable through this ability. + * + * Plugin: core reserves this ability in WP_Settings_Abilities::register() but does not yet + * implement it; the plugin ships it here, reusing the snapshot register_get_settings() computed. + * + * @since x.x.x + */ + private function register_manage_settings(): void { + // Plugin: unregister any core-provided copy first so the plugin's version wins. + if ( wp_has_ability( 'core/manage-settings' ) ) { + wp_unregister_ability( 'core/manage-settings' ); + } + + $settings = (array) $this->exposed_settings; + $properties = array(); + foreach ( $settings as $exposed_name => $setting ) { + $properties[ $exposed_name ] = $setting['schema']; + } + + wp_register_ability( + 'core/manage-settings', + array( + 'label' => __( 'Manage Settings', 'ai' ), + 'description' => __( 'Updates one or more WordPress settings exposed to abilities. Accepts a map of setting name to its new value and returns the updated values.', 'ai' ), + 'category' => self::CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'description' => __( 'A map of setting name to the new value to store. At least one setting is required.', 'ai' ), + 'properties' => $properties, + 'minProperties' => 1, + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'A map of each updated setting name to its new value.', 'ai' ), + 'properties' => $properties, + 'additionalProperties' => false, + ), + 'execute_callback' => array( $this, 'execute_manage_settings' ), + 'permission_callback' => array( $this, 'has_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + /** * Executes the `core/settings` ability. * @@ -175,6 +225,53 @@ public function execute_get_settings( $input = array() ): array { return $result; } + /** + * Executes the `core/manage-settings` ability. + * + * The Abilities API validates the input against the registered input schema (each setting's own + * value schema, with `additionalProperties` disabled) before this runs, so every value reaching + * here is known and valid; an invalid value aborts the call before any option is written. Each + * value is sanitized against its schema and stored, then read back and cast for the response. + * + * @since x.x.x + * + * @param mixed $input The ability input: a map of exposed setting name to its new value. + * @return array Map of each updated setting name to its stored value. + */ + public function execute_manage_settings( $input = array() ): array { + $input = is_array( $input ) ? $input : array(); + + $settings = $this->exposed_settings; + if ( null === $settings ) { + // The cache is populated in register_get_settings() before the ability is + // registered, so this is unreachable in practice; bail defensively otherwise. + return array(); + } + + $result = array(); + foreach ( $input as $exposed_name => $value ) { + if ( ! is_string( $exposed_name ) || ! isset( $settings[ $exposed_name ] ) ) { + // `additionalProperties: false` already rejects unknown keys upstream; guard defensively. + continue; + } + + $setting = $settings[ $exposed_name ]; + + // Sanitize against the declared schema before storing; update_option() additionally + // runs the setting's own registered sanitize_callback. + $value = rest_sanitize_value_from_schema( $value, $setting['schema'], $exposed_name ); + + update_option( $setting['option'], $value ); + + $type = isset( $setting['schema']['type'] ) && is_string( $setting['schema']['type'] ) ? $setting['schema']['type'] : 'string'; + $stored = get_option( $setting['option'], $setting['default'] ); + + $result[ $exposed_name ] = $this->cast_value( $stored, $type ); + } + + return $result; + } + /** * Checks whether the current user may use the settings abilities. * diff --git a/tests/Integration/Includes/Abilities/Settings/SettingsTest.php b/tests/Integration/Includes/Abilities/Settings/SettingsTest.php index 1ced0753..af83b6fd 100644 --- a/tests/Integration/Includes/Abilities/Settings/SettingsTest.php +++ b/tests/Integration/Includes/Abilities/Settings/SettingsTest.php @@ -67,6 +67,9 @@ public function tearDown(): void { if ( wp_has_ability( 'core/settings' ) ) { wp_unregister_ability( 'core/settings' ); } + if ( wp_has_ability( 'core/manage-settings' ) ) { + wp_unregister_ability( 'core/manage-settings' ); + } remove_filter( 'register_setting_args', array( $this->show_in_abilities, 'mark_setting' ), 10 ); unregister_setting( 'general', 'core_settings_ability_test_option' ); @@ -308,4 +311,161 @@ public function test_core_settings_exposes_a_custom_registered_setting(): void { $this->assertSame( array( 'core_settings_ability_test_option' => 7 ), $result ); } + + /** + * The manage ability is registered in the `site` category and flagged writable. + * + * @since x.x.x + */ + public function test_core_manage_settings_ability_is_registered(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/manage-settings' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'site', $ability->get_category() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertFalse( $annotations['readonly'] ); + $this->assertFalse( $annotations['destructive'] ); + } + + /** + * Every setting exposed for reading is writable: the input schema mirrors the exposed set + * and disallows unknown properties. + * + * @since x.x.x + */ + public function test_core_manage_settings_input_schema_mirrors_exposed_settings(): void { + $this->register_ability(); + + $schema = wp_get_ability( 'core/manage-settings' )->get_input_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertFalse( $schema['additionalProperties'] ); + $this->assertSame( 1, $schema['minProperties'] ); + $this->assertArrayHasKey( 'blogname', $schema['properties'] ); + $this->assertArrayHasKey( 'posts_per_page', $schema['properties'] ); + } + + /** + * The ability stores each provided setting and returns the updated, correctly typed values. + * + * @since x.x.x + */ + public function test_core_manage_settings_updates_and_returns_values(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( + 'blogname' => 'Renamed Site', + 'posts_per_page' => 9, + ) + ); + + $this->assertSame( + array( + 'blogname' => 'Renamed Site', + 'posts_per_page' => 9, + ), + $result + ); + // Persisted to the database. + $this->assertSame( 'Renamed Site', get_option( 'blogname' ) ); + $this->assertSame( 9, (int) get_option( 'posts_per_page' ) ); + } + + /** + * An invalid value aborts the whole call before any option is written (all-or-nothing). + * + * @since x.x.x + */ + public function test_core_manage_settings_is_atomic_on_invalid_value(): void { + $this->become_admin(); + $this->register_ability(); + + update_option( 'blogname', 'Original Name' ); + + // `default_ping_status` is constrained to the enum open|closed; `sometimes` is invalid, so + // the whole call must fail and the valid sibling value must not be written. + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( + 'blogname' => 'Should Not Persist', + 'default_ping_status' => 'sometimes', + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + $this->assertSame( 'Original Name', get_option( 'blogname' ) ); + } + + /** + * Unknown setting names are rejected by `additionalProperties: false`. + * + * @since x.x.x + */ + public function test_core_manage_settings_rejects_unknown_setting(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( 'not_a_registered_setting' => 'value' ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /** + * Empty input is rejected: at least one setting must be provided. + * + * @since x.x.x + */ + public function test_core_manage_settings_rejects_empty_input(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( array() ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_input', $result->get_error_code() ); + } + + /** + * Users without `manage_options` cannot run the manage ability, and nothing is written. + * + * @since x.x.x + */ + public function test_core_manage_settings_requires_manage_options(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + $this->register_ability(); + + update_option( 'blogname', 'Original Name' ); + + $result = wp_get_ability( 'core/manage-settings' )->execute( array( 'blogname' => 'Nope' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + $this->assertSame( 'Original Name', get_option( 'blogname' ) ); + } + + /** + * A setting registered with `show_in_abilities` (for example by a plugin) is writable. + * + * @since x.x.x + */ + public function test_core_manage_settings_updates_a_custom_registered_setting(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/manage-settings' )->execute( + array( 'core_settings_ability_test_option' => 100 ) + ); + + $this->assertSame( array( 'core_settings_ability_test_option' => 100 ), $result ); + $this->assertSame( 100, (int) get_option( 'core_settings_ability_test_option' ) ); + } } diff --git a/tests/e2e/specs/abilities/core-manage-settings.spec.js b/tests/e2e/specs/abilities/core-manage-settings.spec.js new file mode 100644 index 00000000..634cd83a --- /dev/null +++ b/tests/e2e/specs/abilities/core-manage-settings.spec.js @@ -0,0 +1,140 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + enableExperiment, + enableExperiments, +} = require( '../../utils/helpers' ); + +/** + * Runs an ability through the client-side Abilities API, exactly as a consumer would in the + * browser. + * + * Mirrors the plugin's own sequence in `src/utils/run-ability.ts`: importing + * `@wordpress/core-abilities` initializes the client store (WordPress core's build runs + * `initialize()` on load and exports the resulting `ready` promise), so we await `ready` before + * calling `executeAbility` from `@wordpress/abilities`. + * + * The client modules are only present in the page's import map once an AI experiment is enabled + * in the block editor (it declares them as `module_dependencies`), which is set up in + * `beforeEach`. + * + * @param {import('@playwright/test').Page} page The Playwright page. + * @param {string} ability The ability name. + * @param {Object} input The ability input. + * @return {Promise} `{ ok: true, result }` or `{ ok: false, code }`. + */ +async function runAbility( page, ability, input ) { + return page.evaluate( + async ( { abilityName, abilityInput } ) => { + const { ready } = await import( '@wordpress/core-abilities' ); + if ( ready ) { + await ready; + } + + const { executeAbility } = await import( '@wordpress/abilities' ); + + try { + const result = await executeAbility( + abilityName, + abilityInput + ); + return { ok: true, result }; + } catch ( e ) { + return { ok: false, code: e && e.code ? e.code : null }; + } + }, + { abilityName: ability, abilityInput: input } + ); +} + +test.describe( 'core/manage-settings ability (client-side Abilities API)', () => { + test.beforeEach( async ( { admin, page } ) => { + // Enabling an experiment loads its block-editor script, which declares the + // `@wordpress/abilities` + `@wordpress/core-abilities` modules as dependencies + // and so adds them to the editor's import map. + await enableExperiments( admin, page ); + await enableExperiment( admin, page, 'Excerpt Generation' ); + + // Run from the block editor, where the abilities client modules are available. + await admin.createNewPost( { + postType: 'post', + title: 'core/manage-settings ability test', + } ); + } ); + + test( 'updates settings and persists the new values', async ( { + page, + } ) => { + // Capture the originals so the test restores site state when it is done. + const before = await runAbility( page, 'core/settings', { + fields: [ 'blogname', 'posts_per_page' ], + } ); + expect( before.ok ).toBe( true ); + const original = before.result; + + const updated = await runAbility( page, 'core/manage-settings', { + blogname: 'Managed Settings E2E', + posts_per_page: 13, + } ); + + expect( updated.ok ).toBe( true ); + expect( updated.result ).toEqual( { + blogname: 'Managed Settings E2E', + posts_per_page: 13, + } ); + + // Read back through the read ability to confirm the values were persisted. + const after = await runAbility( page, 'core/settings', { + fields: [ 'blogname', 'posts_per_page' ], + } ); + expect( after.ok ).toBe( true ); + expect( after.result.blogname ).toBe( 'Managed Settings E2E' ); + expect( after.result.posts_per_page ).toBe( 13 ); + + // Restore the original values. + await runAbility( page, 'core/manage-settings', original ); + } ); + + test( 'rejects an unknown setting', async ( { page } ) => { + // `additionalProperties: false` makes an unregistered key invalid input. + const outcome = await runAbility( page, 'core/manage-settings', { + not_a_registered_setting: 'value', + } ); + + expect( outcome.ok ).toBe( false ); + } ); + + test( 'writes a setting registered by another active plugin', async ( { + page, + } ) => { + // Registered by the `e2e-testing` plugin (mapped in .wp-env.test.json) with + // `show_in_abilities`, so it is both readable and writable. + const before = await runAbility( page, 'core/settings', { + fields: [ 'ai_e2e_sample_setting' ], + } ); + expect( before.ok ).toBe( true ); + const original = before.result; + + const updated = await runAbility( page, 'core/manage-settings', { + ai_e2e_sample_setting: 'managed-value', + } ); + expect( updated.ok ).toBe( true ); + expect( updated.result ).toEqual( { + ai_e2e_sample_setting: 'managed-value', + } ); + + const after = await runAbility( page, 'core/settings', { + fields: [ 'ai_e2e_sample_setting' ], + } ); + expect( after.result.ai_e2e_sample_setting ).toBe( 'managed-value' ); + + // Restore the original value. + await runAbility( page, 'core/manage-settings', original ); + } ); +} );