diff --git a/src/wp-includes/abilities/class-wp-abilities-settings.php b/src/wp-includes/abilities/class-wp-abilities-settings.php index 27ab7c4b10e5d..df0a9ead9c018 100644 --- a/src/wp-includes/abilities/class-wp-abilities-settings.php +++ b/src/wp-includes/abilities/class-wp-abilities-settings.php @@ -12,19 +12,14 @@ /** * Core class used to register settings-related abilities. * - * Provides the read-only `core/settings-get` ability and the shared building blocks - * (exposed-settings discovery and schema generation) that are intended to also back a - * future write-oriented `core/settings-update` ability. + * Provides the read-only `core/settings-get` ability. Its schemas and results use + * the same set of settings, selected when the ability is registered. * - * Unlike the other core abilities, which are self-contained closures registered directly - * in wp_register_core_abilities(), the settings abilities live in a dedicated class - * because they share state: the set of exposed settings is computed once at registration - * and reused by the input schema, the output schema, and the execute callback, and the - * same helpers are meant to be shared with the future write ability. - * - * The exposed settings are captured when the ability registers, the first time the abilities - * registry is used in a request. Settings registered later in that request are not exposed. - * Core registers its own settings in time, see _wp_register_initial_settings_for_abilities(). + * Abilities are registered on `wp_abilities_api_init`, when the abilities registry + * is first used after `init`. Core settings and plugin settings with `show_in_abilities` + * must be registered on `init` or earlier so they are available at that point. + * Filters that change setting arguments must be attached before the settings are + * registered. Later changes are not reflected in an already registered ability. * * 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. @@ -45,7 +40,10 @@ final class WP_Abilities_Settings { private const CATEGORY = 'site'; /** - * Settings exposed through the Abilities API, computed once at registration. + * Settings available to the ability when it was registered. + * + * Keeping this set unchanged ensures that the ability returns only settings + * described by its schemas. * * @since 7.2.0 * @var array}> diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index cdeaa169af0cd..1ea9b7bad3458 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -549,14 +549,14 @@ // REST API actions. add_action( 'init', 'rest_api_init' ); add_action( 'rest_api_init', 'rest_api_default_filters', 10, 1 ); -add_action( 'rest_api_init', 'register_initial_settings', 10 ); +// Register settings before either the REST API or the Abilities API needs their metadata. +add_action( 'init', 'register_initial_settings', 10 ); add_filter( 'rest_pre_update_setting', 'rest_restrict_privacy_policy_page_setting_update', 10, 2 ); add_action( 'rest_api_init', 'create_initial_rest_routes', 99 ); add_action( 'parse_request', 'rest_api_loaded' ); // Abilities API. add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); -add_action( 'wp_abilities_api_init', '_wp_register_initial_settings_for_abilities', 1 ); add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); // Connectors API. diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index e90f97fd2025a..2fc3bda6035fb 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -60,7 +60,12 @@ function get_locale() { if ( wp_installing() ) { $ms_locale = get_site_option( 'WPLANG' ); } else { - $ms_locale = get_option( 'WPLANG' ); + /* + * A missing site language must fall back to the network language. An explicit + * false default prevents the registered WPLANG default from hiding that case + * when the locale is calculated after core settings have been registered. + */ + $ms_locale = get_option( 'WPLANG', false ); if ( false === $ms_locale ) { $ms_locale = get_site_option( 'WPLANG' ); } diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 6529f44d2983c..c5dae6b16120c 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2738,15 +2738,23 @@ function set_site_transient( $transient, $value, $expiration = 0 ) { /** * Registers default settings available in WordPress. * - * The settings registered here are primarily useful for the REST API and the - * Abilities API, so this does not encompass all settings available in WordPress. + * Registers metadata for core settings used by the REST API and the Abilities API. + * This runs on `init` so the metadata is available whichever API is used first. + * It does not cover every WordPress setting or change which options admin forms can save. * * @since 4.7.0 * @since 6.0.1 The `show_on_front`, `page_on_front`, and `page_for_posts` options were added. * @since 7.2.0 The `wp_page_for_privacy_policy` option was registered, exposed as `page_for_privacy_policy`. * @since 7.2.0 Added `show_in_abilities` support for the exposed settings. + * Registration runs on `init` without changing the admin allowed-options lists. + * + * @global array $new_allowed_options Additional options that admin settings forms may save. */ function register_initial_settings() { + global $new_allowed_options; + + $allowed_options = $new_allowed_options; + register_setting( 'general', 'blogname', @@ -2996,38 +3004,11 @@ function register_initial_settings() { 'description' => __( 'Allow people to submit comments on new posts.' ), ) ); -} - -/** - * Registers the default settings when the Abilities API initializes. - * - * The default settings are registered on `rest_api_init`, which fires lazily and - * independently of `wp_abilities_api_init`: on cron, WP-CLI, or any request where - * abilities are used before the REST server loads, it may not have fired, or may be - * mid-fire at a priority before register_initial_settings() runs. This makes sure the - * settings exist before the core abilities read them. Registering them again later on - * `rest_api_init` is harmless. - * - * @since 7.2.0 - * @access private - * - * @global array $new_allowed_options - */ -function _wp_register_initial_settings_for_abilities(): void { - global $new_allowed_options; - - if ( did_action( 'rest_api_init' ) && ! doing_action( 'rest_api_init' ) ) { - return; - } - - $allowed_options = $new_allowed_options; - - register_initial_settings(); /* - * Registering a setting also allows it on the options screen of its group. Restore - * the list, so saving Settings > General does not try to save `admin_email`, which - * that screen sends as `new_admin_email`. + * Core settings screens define which options their forms may save. Registering metadata + * must preserve those lists. For example, the email form submits new_admin_email so that + * the address can be confirmed before admin_email changes. */ $new_allowed_options = $allowed_options; } @@ -3035,6 +3016,11 @@ function _wp_register_initial_settings_for_abilities(): void { /** * Registers a setting and its data. * + * Settings exposed through the Abilities API must be registered on `init` or earlier. + * Attach filters that change setting arguments before registering the setting. + * Abilities may read the settings as soon as `init` finishes, so later registrations + * trigger an incorrect usage notice but still register the setting for other consumers. + * * @since 2.7.0 * @since 3.0.0 The `misc` option group was deprecated. * @since 3.5.0 The `privacy` option group was deprecated. @@ -3068,7 +3054,7 @@ function _wp_register_initial_settings_for_abilities(): void { * When true, it uses the same name and schema as `$show_in_rest`. * It may also be an array with 'name' and 'schema' keys, used instead of * `$show_in_rest` rather than merged with it. - * Settings registered after abilities initialize are not exposed. + * Register the setting on `init` or earlier. * @type mixed $default Default value when calling `get_option()`. * } */ @@ -3112,6 +3098,18 @@ function register_setting( $option_group, $option_name, $args = array() ) { $args = wp_parse_args( $args, $defaults ); + if ( ! empty( $args['show_in_abilities'] ) && did_action( 'init' ) && ! doing_action( 'init' ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: %s: Setting name. */ + __( 'The setting "%s" is exposed to abilities and must be registered on the init action or earlier.' ), + $option_name + ), + '7.2.0' + ); + } + // Require an item schema when registering settings with an array type. if ( false !== $args['show_in_rest'] && 'array' === $args['type'] && ( ! is_array( $args['show_in_rest'] ) || ! isset( $args['show_in_rest']['schema']['items'] ) ) ) { _doing_it_wrong( __FUNCTION__, __( 'When registering an "array" setting to show in the REST API, you must specify the schema for each array item in "show_in_rest.schema.items".' ), '5.4.0' ); diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php index b1efe8b84bd66..6e887fb236673 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsGetAbility.php @@ -6,7 +6,7 @@ * Tests for the core/settings-get ability shipped with the Abilities API. * * @covers wp_register_core_abilities - * @covers _wp_register_initial_settings_for_abilities + * @covers register_initial_settings * @covers WP_Abilities_Settings * * @group abilities-api @@ -14,41 +14,38 @@ class Tests_Abilities_API_WpRegisterCoreSettingsGetAbility extends WP_UnitTestCase { /** - * Backup of the `$wp_registered_settings` global, restored after the class. + * Registered settings to restore after these tests. * * @var array|null */ private static $registered_settings_backup; /** - * Registers the core abilities before the class. + * Makes core abilities and a custom setting available to these tests. * - * The ability is registered under the ordering that used to break it: no settings - * registered yet and `rest_api_init` never fired, as on cron, WP-CLI, or any request - * that uses the Abilities API before the REST server loads. Core must register its - * initial settings when abilities initialize (see _wp_register_initial_settings_for_abilities()). + * The test bootstrap has already registered core settings during init. * * @since 7.2.0 */ public static function wpSetUpBeforeClass(): void { - global $wp_registered_settings, $wp_actions; + global $wp_registered_settings; self::$registered_settings_backup = $wp_registered_settings; - $rest_api_init_count = $wp_actions['rest_api_init'] ?? null; - $wp_registered_settings = array(); - unset( $wp_actions['rest_api_init'] ); - // A non-core setting flagged for the Abilities API, to verify that any registered - // setting (not just the core ones) is exposed by the ability. - register_setting( - 'general', - 'core_settings_get_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, - ) + // Include a custom setting to verify that plugins can expose settings too. + self::run_on_init( + static function () { + register_setting( + 'general', + 'core_settings_get_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. @@ -61,7 +58,7 @@ public static function wpSetUpBeforeClass(): void { do_action( 'wp_abilities_api_init' ); /* - * Restore the hooks and the `rest_api_init` count right away instead of after the class. + * Restore the hooks right away instead of after the class. * The first test of a run snapshots the hooks and every test resets them to that snapshot, * so changes left here would leak into every later test whenever this class runs first. */ @@ -69,13 +66,10 @@ public static function wpSetUpBeforeClass(): void { remove_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); - if ( null !== $rest_api_init_count ) { - $wp_actions['rest_api_init'] = $rest_api_init_count; - } } /** - * Cleans up registered abilities, categories and settings after the class. + * Restores settings and removes test abilities so they do not affect other tests. * * @since 7.2.0 */ @@ -94,10 +88,36 @@ public static function wpTearDownAfterClass(): void { } /** - * Registers the core/settings-get ability again inside a faked init action. + * Provides the init context required to register settings used by a test. + * + * The test bootstrap has already completed init. Simulating its context avoids + * rerunning unrelated callbacks attached to that action. + * + * @param callable $callback The registration callback. + */ + private static function run_on_init( callable $callback ): void { + global $wp_current_filter; + + $wp_current_filter[] = 'init'; + try { + $callback(); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Core settings metadata must be available without starting a REST API request. * - * The class setup has already registered it through wp_register_core_abilities(), so - * the existing copy is unregistered first. + * @ticket 64605 + */ + public function test_core_settings_registration_runs_on_init(): void { + $this->assertSame( 10, has_action( 'init', 'register_initial_settings' ) ); + $this->assertFalse( has_action( 'rest_api_init', 'register_initial_settings' ) ); + } + + /** + * Refreshes the ability to include settings added or changed by a test. */ private function register_ability(): void { if ( wp_has_ability( 'core/settings-get' ) ) { @@ -106,72 +126,126 @@ private function register_ability(): void { global $wp_current_filter; $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. - ( new WP_Abilities_Settings() )->register(); + try { + ( new WP_Abilities_Settings() )->register(); + } finally { + array_pop( $wp_current_filter ); + } } /** - * Logs in as an administrator so abilities gated behind `manage_options` can run. + * Uses an administrator account with permission to read settings. */ private function become_admin(): void { wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); } /** - * Core settings are exposed even when the abilities registry initializes in a request - * where `rest_api_init` (which registers core's initial settings) has never fired. - * - * The class setup registers the ability with no settings registered up front, so this - * asserts that core registered its initial settings when abilities initialized. + * The ability exposes core settings registered during init by the test bootstrap. * * @ticket 64605 */ - public function test_core_settings_get_exposes_initial_settings_without_rest_api_init(): void { + public function test_core_settings_get_exposes_registered_core_settings(): void { $ability = wp_get_ability( 'core/settings-get' ); - $this->assertArrayHasKey( 'title', $ability->get_output_schema()['properties'], 'The output schema should describe the site title, registered when abilities initialized.' ); + $this->assertArrayHasKey( 'title', $ability->get_output_schema()['properties'], 'The output schema should describe the site title, registered during init.' ); $this->become_admin(); $result = $ability->execute( array( 'fields' => array( 'title' ) ) ); - $this->assertArrayHasKey( 'title', $result, 'The site title should be returned, registered when abilities initialized.' ); + $this->assertArrayHasKey( 'title', $result, 'The site title should be returned, registered during init.' ); } /** - * Tests that registering initial settings for abilities does not pollute $new_allowed_options. + * Registering core metadata must not change the options saved by admin forms. * * @ticket 64605 */ public function test_register_preserves_new_allowed_options(): void { global $new_allowed_options; - $prev_actions_count = $GLOBALS['wp_actions']['rest_api_init'] ?? null; - $prev_allowed_backup = $new_allowed_options; - unset( $GLOBALS['wp_actions']['rest_api_init'] ); + $backup = $new_allowed_options; + $expected = array( 'general' => array( 'my_custom_option' ) ); + try { + $new_allowed_options = $expected; + self::run_on_init( 'register_initial_settings' ); - // Simulate an existing custom setting already in $new_allowed_options. - $new_allowed_options = array( - 'general' => array( 'my_custom_option' ), - ); + $this->assertSame( $expected, $new_allowed_options ); + } finally { + $new_allowed_options = $backup; + } + } + /** + * A registration filter can exclude a core setting from the ability schemas. + * + * @ticket 64605 + */ + public function test_registration_filter_can_hide_a_core_setting(): void { + global $wp_registered_settings; + + $backup = $wp_registered_settings; + $filter = static function ( $args, $defaults, $group, $name ) { + if ( 'blogname' === $name ) { + $args['show_in_abilities'] = false; + } + return $args; + }; + + add_filter( 'register_setting_args', $filter, 10, 4 ); try { - _wp_register_initial_settings_for_abilities(); + self::run_on_init( 'register_initial_settings' ); + $this->register_ability(); + $ability = wp_get_ability( 'core/settings-get' ); - // 'admin_email' must NOT be in $new_allowed_options['general']. - $this->assertNotContains( 'admin_email', $new_allowed_options['general'], 'Registering the initial settings for abilities should not allow admin_email on the general options screen.' ); - // Prior allowed options must be preserved. - $this->assertContains( 'my_custom_option', $new_allowed_options['general'], 'The options allowed before should still be allowed.' ); + $this->assertArrayNotHasKey( 'title', $ability->get_output_schema()['properties'] ); + $this->assertNotContains( 'title', $ability->get_input_schema()['properties']['fields']['items']['enum'] ); } finally { - $new_allowed_options = $prev_allowed_backup; - if ( null === $prev_actions_count ) { - unset( $GLOBALS['wp_actions']['rest_api_init'] ); - } else { - $GLOBALS['wp_actions']['rest_api_init'] = $prev_actions_count; + remove_filter( 'register_setting_args', $filter, 10 ); + $wp_registered_settings = $backup; + $this->register_ability(); + } + } + + /** + * A filter can expose a plugin setting with a custom public name and label. + * + * The ability must use the filtered metadata in its schema and results. + * + * @ticket 64605 + */ + public function test_registration_filter_controls_plugin_setting_schema(): void { + $option = 'filtered_ability_setting'; + $filter = static function ( $args, $defaults, $group, $name ) use ( $option ) { + if ( $option === $name ) { + $args['show_in_abilities'] = array( 'name' => 'public_setting' ); + $args['label'] = 'Filtered label'; } + return $args; + }; + + add_filter( 'register_setting_args', $filter, 10, 4 ); + try { + self::run_on_init( + static function () use ( $option ) { + register_setting( 'general', $option, array( 'default' => 'plugin value' ) ); + } + ); + $this->register_ability(); + $this->become_admin(); + $ability = wp_get_ability( 'core/settings-get' ); + + $this->assertSame( 'Filtered label', $ability->get_output_schema()['properties']['public_setting']['title'] ); + $this->assertSame( array( 'public_setting' => 'plugin value' ), $ability->execute( array( 'fields' => array( 'public_setting' ) ) ) ); + } finally { + remove_filter( 'register_setting_args', $filter, 10 ); + unregister_setting( 'general', $option ); + $this->register_ability(); } } /** - * Neither settings ability is registered when no setting is exposed to abilities. + * The settings ability is not registered when no settings are exposed to it. * * @ticket 64605 */ @@ -239,28 +313,32 @@ public function test_core_settings_get_uses_rest_api_setting_names(): void { * @ticket 64605 */ public function test_core_settings_get_inherits_rest_api_exposure(): void { - register_setting( - 'general', - 'core_settings_get_inherit_test_option', - array( - 'show_in_rest' => array( - 'name' => 'inherited_name', - 'schema' => array( 'enum' => array( 'a', 'b' ) ), - ), - 'show_in_abilities' => true, - ) - ); - register_setting( - 'general', - 'core_settings_get_override_test_option', - array( - 'show_in_rest' => array( - 'name' => 'rest_name', - ), - 'show_in_abilities' => array( - 'name' => 'ability_name', - ), - ) + self::run_on_init( + static function () { + register_setting( + 'general', + 'core_settings_get_inherit_test_option', + array( + 'show_in_rest' => array( + 'name' => 'inherited_name', + 'schema' => array( 'enum' => array( 'a', 'b' ) ), + ), + 'show_in_abilities' => true, + ) + ); + register_setting( + 'general', + 'core_settings_get_override_test_option', + array( + 'show_in_rest' => array( + 'name' => 'rest_name', + ), + 'show_in_abilities' => array( + 'name' => 'ability_name', + ), + ) + ); + } ); try { @@ -488,13 +566,17 @@ public function test_core_settings_get_reads_stored_values( string $type, $store // A numeric name, which PHP turns into an integer array key, must still match `fields`. $option = '123'; - register_setting( - 'general', - $option, - array( - 'type' => $type, - 'show_in_abilities' => array( 'schema' => $schema ), - ) + self::run_on_init( + static function () use ( $option, $type, $schema ) { + register_setting( + 'general', + $option, + array( + 'type' => $type, + 'show_in_abilities' => array( 'schema' => $schema ), + ) + ); + } ); update_option( $option, $stored ); @@ -512,7 +594,7 @@ public function test_core_settings_get_reads_stored_values( string $type, $store } /** - * Data provider. + * Provides stored values that need type conversion or fail schema validation. * * @return array}> Stored values, and the JSON they are read as. */ @@ -550,13 +632,17 @@ public static function data_stored_values(): array { public function test_core_settings_get_skips_a_setting_with_an_unsupported_type(): void { $option = 'core_settings_get_ability_type_test_option'; - register_setting( - 'general', - $option, - array( - 'type' => 'foo', - 'show_in_abilities' => true, - ) + self::run_on_init( + static function () use ( $option ) { + register_setting( + 'general', + $option, + array( + 'type' => 'foo', + 'show_in_abilities' => true, + ) + ); + } ); update_option( $option, 'value' ); diff --git a/tests/phpunit/tests/l10n/getLocale.php b/tests/phpunit/tests/l10n/getLocale.php index 351dafb3eff3f..fc9fb3492525a 100644 --- a/tests/phpunit/tests/l10n/getLocale.php +++ b/tests/phpunit/tests/l10n/getLocale.php @@ -42,6 +42,7 @@ public function test_local_option_should_take_precedence_on_multisite() { public function test_network_option_should_be_fallback_on_multisite() { global $locale; + // Recalculate after init, when WPLANG already has a registered default of en_US. $locale = null; update_site_option( 'WPLANG', 'es_ES' ); @@ -49,6 +50,25 @@ public function test_network_option_should_be_fallback_on_multisite() { $this->assertSame( 'es_ES', get_locale() ); } + /** + * An empty site language selects English instead of the network language. + * + * @ticket 64605 + * @group ms-required + * + * @global string $locale The current locale. + */ + public function test_empty_site_language_should_take_precedence_on_multisite() { + global $locale; + + $locale = null; + + update_option( 'WPLANG', '' ); + update_site_option( 'WPLANG', 'es_ES' ); + + $this->assertSame( 'en_US', get_locale() ); + } + /** * @group ms-excluded * diff --git a/tests/phpunit/tests/option/registration.php b/tests/phpunit/tests/option/registration.php index 48ee025d2536d..1b8424241e42b 100644 --- a/tests/phpunit/tests/option/registration.php +++ b/tests/phpunit/tests/option/registration.php @@ -5,6 +5,109 @@ */ class Tests_Option_Registration extends WP_UnitTestCase { + /** + * Settings exposed to abilities must be registered before init finishes. + * + * Later registrations must warn developers because abilities may already have + * read the settings. Settings not exposed to abilities have no such restriction. + * + * @ticket 64605 + * @covers ::register_setting + * @dataProvider data_ability_setting_registration_timing + * + * @param string $timing Registration timing relative to init. + * @param bool|array $exposure The show_in_abilities argument. + * @param bool $expect_notice Whether an incorrect usage notice is expected. + */ + public function test_ability_setting_registration_timing( $timing, $exposure, $expect_notice ) { + global $wp_actions, $wp_current_filter; + + $actions_backup = $wp_actions; + $filter_backup = $wp_current_filter; + if ( $expect_notice ) { + $this->setExpectedIncorrectUsage( 'register_setting' ); + } + + try { + if ( 'before' === $timing ) { + unset( $wp_actions['init'] ); + } else { + $wp_actions['init'] = 1; + } + if ( 'during' === $timing ) { + $wp_current_filter[] = 'init'; + } + + register_setting( 'test_group', 'test_option', array( 'show_in_abilities' => $exposure ) ); + + // The notice must not prevent other code from using the setting. + $this->assertSame( $exposure, get_registered_settings()['test_option']['show_in_abilities'] ); + } finally { + unregister_setting( 'test_group', 'test_option' ); + $wp_actions = $actions_backup; + $wp_current_filter = $filter_backup; + } + } + + /** + * Provides registration times and exposure options covered by the notice. + * + * @return array Registration timing, exposure, and whether a notice is expected. + */ + public static function data_ability_setting_registration_timing() { + return array( + 'before init' => array( 'before', true, false ), + 'during init' => array( 'during', true, false ), + 'after init' => array( 'after', true, true ), + 'custom public name after init' => array( 'after', array( 'name' => 'public_name' ), true ), + 'not exposed after init' => array( 'after', false, false ), + ); + } + + /** + * The late-registration notice must use the exposure value set by filters. + * + * Filters can enable or disable exposure, regardless of the original arguments. + * + * @ticket 64605 + * @covers ::register_setting + * @dataProvider data_filtered_ability_exposure + * + * @param bool $exposed Whether the filter exposes the setting to abilities. + */ + public function test_late_registration_checks_filtered_exposure( $exposed ) { + // The test bootstrap has already completed init. + if ( $exposed ) { + $this->setExpectedIncorrectUsage( 'register_setting' ); + } + $filter = static function ( $args ) use ( $exposed ) { + $args['show_in_abilities'] = $exposed; + return $args; + }; + + add_filter( 'register_setting_args', $filter ); + try { + register_setting( 'test_group', 'test_option', array( 'show_in_abilities' => ! $exposed ) ); + + $this->assertSame( $exposed, get_registered_settings()['test_option']['show_in_abilities'] ); + } finally { + remove_filter( 'register_setting_args', $filter ); + unregister_setting( 'test_group', 'test_option' ); + } + } + + /** + * Provides filters that enable or disable exposure to abilities. + * + * @return array Exposure values set by the filter. + */ + public static function data_filtered_ability_exposure() { + return array( + 'filter enables exposure' => array( true ), + 'filter disables exposure' => array( false ), + ); + } + /** * @covers ::register_setting */ diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php index 278041158ef90..4174b48f64b73 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesSettingsController.php @@ -43,23 +43,17 @@ class Tests_REST_API_WpRestAbilitiesSettingsController extends WP_UnitTestCase { /** * Sets up users and registers the core abilities. * - * The abilities are registered as on a request that uses the Abilities API before - * the REST server loads, so core must register its initial settings when abilities - * initialize (see _wp_register_initial_settings_for_abilities()). + * The test bootstrap has already registered core settings during init, before + * either the abilities registry or the REST server needs their metadata. * * @since 7.2.0 * * @param WP_UnitTest_Factory $factory The unit test factory. */ public static function wpSetUpBeforeClass( $factory ): void { - global $wp_actions; - self::$admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); - $rest_api_init_count = $wp_actions['rest_api_init'] ?? null; - unset( $wp_actions['rest_api_init'] ); - remove_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); remove_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); @@ -76,18 +70,14 @@ public static function wpSetUpBeforeClass( $factory ): void { do_action( 'wp_abilities_api_init' ); /* - * Restore the hooks and the `rest_api_init` count right away instead of after the - * class. The first test of a run snapshots the hooks and every test resets them to - * that snapshot, so changes left here would leak into every later test whenever this - * class runs first. + * Restore the hooks right away instead of after the class. The first test of a run + * snapshots the hooks and every test resets them to that snapshot, so changes left + * here would leak into every later test whenever this class runs first. */ remove_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); remove_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); add_action( 'wp_abilities_api_categories_init', '_unhook_core_ability_categories_registration', 1 ); add_action( 'wp_abilities_api_init', '_unhook_core_abilities_registration', 1 ); - if ( null !== $rest_api_init_count ) { - $wp_actions['rest_api_init'] = $rest_api_init_count; - } } /**