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
109 changes: 99 additions & 10 deletions src/wp-includes/abilities/class-wp-settings-abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
/**
* Core class used to register settings-related abilities.
*
* Provides the read-only `core/settings` ability and the shared building blocks
* (exposed-settings discovery, schema generation, value casting) that are intended to
* also back a future write-oriented `core/manage-settings` ability.
* Provides the read-only `core/settings` ability and the write-oriented `core/manage-settings`
* ability, plus the shared building blocks (exposed-settings discovery, schema generation, value
* casting) that back both.
*
* This class is part of WordPress' internal implementation of the core abilities and is
* not part of the public API. It may be changed or removed at any time without notice.
Expand Down Expand Up @@ -54,13 +54,7 @@ final class WP_Settings_Abilities {
*/
public function register(): void {
$this->register_get_settings();

/*
* A future write-oriented ability can be registered here, reusing the shared
* helpers below (get_exposed_settings(), value_schema(), cast_value()):
*
* $this->register_manage_settings();
*/
$this->register_manage_settings();
}

/**
Expand Down Expand Up @@ -111,6 +105,54 @@ private function register_get_settings(): void {
);
}

/**
* Registers the write-oriented `core/manage-settings` ability.
*
* The input and output schemas reuse each exposed setting's own schema, so every setting
* readable via `core/settings` is also writable through this ability.
*
* @since 7.1.0
*/
private function register_manage_settings(): void {
$settings = (array) $this->exposed_settings;
$properties = array();
foreach ( $settings as $exposed_name => $setting ) {
$properties[ $exposed_name ] = $setting['schema'];
}

wp_register_ability(
'core/manage-settings',
array(
'label' => __( 'Manage Settings' ),
'description' => __( 'Updates one or more WordPress settings exposed to abilities. Accepts a map of setting name to its new value and returns the updated values.' ),
'category' => self::CATEGORY,
'input_schema' => array(
'type' => 'object',
'description' => __( 'A map of setting name to the new value to store. At least one setting is required.' ),
'properties' => $properties,
'minProperties' => 1,
'additionalProperties' => false,
),
'output_schema' => array(
'type' => 'object',
'description' => __( 'A map of each updated setting name to its new value.' ),
'properties' => $properties,
'additionalProperties' => false,
),
'execute_callback' => array( $this, 'execute_manage_settings' ),
'permission_callback' => array( $this, 'has_permission' ),
'meta' => array(
'annotations' => array(
'readonly' => false,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
)
);
}

/**
* Executes the `core/settings` ability.
*
Expand Down Expand Up @@ -150,6 +192,53 @@ public function execute_get_settings( $input = array() ): array {
return $result;
}

/**
* Executes the `core/manage-settings` ability.
*
* The Abilities API validates the input against the registered input schema (each setting's own
* value schema, with `additionalProperties` disabled) before this runs, so every value reaching
* here is known and valid; an invalid value aborts the call before any option is written. Each
* value is sanitized against its schema and stored, then read back and cast for the response.
*
* @since 7.1.0
*
* @param mixed $input The ability input: a map of exposed setting name to its new value.
* @return array<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
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
<?php

declare( strict_types=1 );

/**
* Tests for the core/manage-settings ability shipped with the Abilities API.
*
* @covers wp_register_core_abilities
* @covers WP_Settings_Abilities
*
* @group abilities-api
*/
class Tests_Abilities_API_WpRegisterCoreManageSettingsAbility extends WP_UnitTestCase {

/**
* Set up before the class.
*
* The core settings are registered on `rest_api_init`, so register them up front to
* mirror the request context in which the ability builds its schema and runs.
*
* @since 7.1.0
*/
public static function set_up_before_class(): void {
parent::set_up_before_class();

register_initial_settings();

// A non-core setting flagged for the Abilities API, to verify that any registered
// setting (not just the core ones) is writable through the ability.
register_setting(
'general',
'core_settings_ability_test_option',
array(
'type' => 'integer',
'label' => 'Custom Ability Setting',
'description' => 'A custom setting exposed through the Abilities API.',
'show_in_abilities' => true,
'default' => 42,
)
);

// Temporarily remove the unhook functions so we can register core abilities.
remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 );
remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 );

add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' );
add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' );
do_action( 'wp_abilities_api_categories_init' );
do_action( 'wp_abilities_api_init' );
}

/**
* Tear down after the class.
*
* @since 7.1.0
*/
public static function tear_down_after_class(): void {
add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 );
add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 );

foreach ( wp_get_abilities() as $ability ) {
wp_unregister_ability( $ability->get_name() );
}
foreach ( wp_get_ability_categories() as $ability_category ) {
wp_unregister_ability_category( $ability_category->get_slug() );
}

unregister_setting( 'general', 'core_settings_ability_test_option' );

parent::tear_down_after_class();
}

/**
* Logs in as an administrator so abilities gated behind `manage_options` can run.
*/
private function become_admin(): void {
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
}

/**
* The ability is registered in the `site` category and flagged writable.
*
* @ticket 64146
*/
public function test_core_manage_settings_ability_is_registered(): void {
$ability = wp_get_ability( 'core/manage-settings' );

$this->assertInstanceOf( WP_Ability::class, $ability );
$this->assertSame( 'site', $ability->get_category() );
$this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) );

$annotations = $ability->get_meta_item( 'annotations', array() );
$this->assertFalse( $annotations['readonly'] );
$this->assertFalse( $annotations['destructive'] );
}

/**
* Every setting exposed for reading is writable: the input schema mirrors the exposed set
* and disallows unknown properties.
*
* @ticket 64146
*/
public function test_core_manage_settings_input_schema_mirrors_exposed_settings(): void {
$schema = wp_get_ability( 'core/manage-settings' )->get_input_schema();

$this->assertSame( 'object', $schema['type'] );
$this->assertFalse( $schema['additionalProperties'] );
$this->assertSame( 1, $schema['minProperties'] );
$this->assertArrayHasKey( 'blogname', $schema['properties'] );
$this->assertArrayHasKey( 'posts_per_page', $schema['properties'] );
}

/**
* The ability stores each provided setting and returns the updated, correctly typed values.
*
* @ticket 64146
*/
public function test_core_manage_settings_updates_and_returns_values(): void {
$this->become_admin();

$result = wp_get_ability( 'core/manage-settings' )->execute(
array(
'blogname' => 'Renamed Site',
'posts_per_page' => 9,
)
);

$this->assertSame(
array(
'blogname' => 'Renamed Site',
'posts_per_page' => 9,
),
$result
);
// Persisted to the database.
$this->assertSame( 'Renamed Site', get_option( 'blogname' ) );
$this->assertSame( 9, (int) get_option( 'posts_per_page' ) );
}

/**
* An invalid value aborts the whole call before any option is written (all-or-nothing).
*
* @ticket 64146
*/
public function test_core_manage_settings_is_atomic_on_invalid_value(): void {
$this->become_admin();

update_option( 'blogname', 'Original Name' );

// `default_ping_status` is constrained to the enum open|closed; `sometimes` is invalid, so
// the whole call must fail and the valid sibling value must not be written.
$result = wp_get_ability( 'core/manage-settings' )->execute(
array(
'blogname' => 'Should Not Persist',
'default_ping_status' => 'sometimes',
)
);

$this->assertWPError( $result );
$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
$this->assertSame( 'Original Name', get_option( 'blogname' ) );
}

/**
* Unknown setting names are rejected by `additionalProperties: false`.
*
* @ticket 64146
*/
public function test_core_manage_settings_rejects_unknown_setting(): void {
$this->become_admin();

$result = wp_get_ability( 'core/manage-settings' )->execute(
array( 'not_a_registered_setting' => 'value' )
);

$this->assertWPError( $result );
$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
}

/**
* Empty input is rejected: at least one setting must be provided.
*
* @ticket 64146
*/
public function test_core_manage_settings_rejects_empty_input(): void {
$this->become_admin();

$result = wp_get_ability( 'core/manage-settings' )->execute( array() );

$this->assertWPError( $result );
$this->assertSame( 'ability_invalid_input', $result->get_error_code() );
}

/**
* Users without `manage_options` cannot run the ability, and nothing is written.
*
* @ticket 64146
*/
public function test_core_manage_settings_requires_manage_options(): void {
wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) );

update_option( 'blogname', 'Original Name' );

$result = wp_get_ability( 'core/manage-settings' )->execute( array( 'blogname' => 'Nope' ) );

$this->assertWPError( $result );
$this->assertSame( 'ability_invalid_permissions', $result->get_error_code() );
$this->assertSame( 'Original Name', get_option( 'blogname' ) );
}

/**
* A setting registered with `show_in_abilities` (for example by a plugin) is writable.
*
* @ticket 64146
*/
public function test_core_manage_settings_updates_a_custom_registered_setting(): void {
$this->become_admin();

$result = wp_get_ability( 'core/manage-settings' )->execute(
array( 'core_settings_ability_test_option' => 100 )
);

$this->assertSame( array( 'core_settings_ability_test_option' => 100 ), $result );
$this->assertSame( 100, (int) get_option( 'core_settings_ability_test_option' ) );
}
}
Loading