Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 108 additions & 11 deletions includes/Abilities/Settings/Settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<string, mixed> 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.
*
Expand Down
160 changes: 160 additions & 0 deletions tests/Integration/Includes/Abilities/Settings/SettingsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down Expand Up @@ -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' ) );
}
}
Loading
Loading