diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 022fb29..699256b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,13 +281,19 @@ jobs: # and WordPress checkout and needs neither `vendor/` nor the WordPress # test suite. smoke: - name: "Smoke / PHP ${{ matrix.php }}" + name: "Smoke / PHP ${{ matrix.php }}${{ matrix.wp == 'nightly' && ' / WP nightly' || '' }}" needs: static runs-on: ubuntu-latest strategy: fail-fast: false matrix: php: ['7.4', '8.3', '8.5'] + wp: ['latest'] + # A WordPress that ships the Secrets API: the plugin loads none of its own + # copy and the commands run on core's implementation (ADR 0014). + include: + - php: '8.3' + wp: 'nightly' services: mysql: image: mysql:8.0 @@ -312,4 +318,4 @@ jobs: extensions: sodium, mysqli coverage: none - - run: make smoke DB_HOST=127.0.0.1 + - run: make smoke DB_HOST=127.0.0.1 WP_VERSION=${{ matrix.wp }} diff --git a/README.md b/README.md index d77e493..1d2d950 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ be copied verbatim into `wordpress-develop` — same paths, same coding style, s domain. Once core ships the API, the plugin stands down on its own and says so in an admin notice rather -than shadowing core's implementation. +than shadowing core's implementation. It keeps providing `wp secret` and `wp network-secret`, which +core leaves out, running on core's implementation. > **Status: feature plugin, pre-core-merge.** The public API surface is settled — it is the one > described in the proposal. See [`docs/journal/open-questions.md`](docs/journal/open-questions.md) for what is diff --git a/cli/class-wp-cli-secret-command.php b/cli/class-wp-cli-secret-command.php index 1669e23..5a467a9 100644 --- a/cli/class-wp-cli-secret-command.php +++ b/cli/class-wp-cli-secret-command.php @@ -422,6 +422,9 @@ public function import_option( array $args ): void { * There is no delete step. Removing a legacy option once the migration is * verified is left to the operator, using wp option delete. * + * Not available on a WordPress that ships the Secrets API: the migrator loads + * only with this plugin's own copy of the API. Migrate before upgrading. + * * ## OPTIONS * * [--dry-run] @@ -463,6 +466,12 @@ public function migrate_legacy( array $args, array $assoc_args ): void { return; } + if ( ! class_exists( 'Secrets_API_Migrator', false ) ) { + WP_CLI::error( 'migrate-legacy is not available on a WordPress that ships the Secrets API. Migrate legacy secrets before upgrading.' ); + + return; + } + $map = array(); foreach ( explode( ',', $this->string_arg( $assoc_args, 'map' ) ) as $pair ) { @@ -640,6 +649,12 @@ public function generate_key(): void { * @param array $assoc_args Associative arguments. */ public function health( array $args, array $assoc_args ): void { + // Core loads its Site Health tests only for the Site Health screen, so on a + // WordPress that ships the API they are not loaded under WP-CLI. + if ( ! function_exists( 'wp_secrets_site_health_test_key_source' ) ) { + require_once ABSPATH . 'wp-admin/includes/secrets-site-health.php'; + } + $results = array( wp_secrets_site_health_test_key_source(), wp_secrets_site_health_test_undecryptable(), diff --git a/docs/decisions/0014-the-plugin-keeps-wp-cli-on-a-core-that-ships-the-api.md b/docs/decisions/0014-the-plugin-keeps-wp-cli-on-a-core-that-ships-the-api.md new file mode 100644 index 0000000..b51d01a --- /dev/null +++ b/docs/decisions/0014-the-plugin-keeps-wp-cli-on-a-core-that-ships-the-api.md @@ -0,0 +1,49 @@ +--- +title: "ADR 0014: The plugin keeps WP-CLI on a core that ships the API" +description: "Once WordPress ships the Secrets API, the plugin loads none of its own copy but still registers wp secret and wp network-secret, running on core's implementation. migrate-legacy does not come along." +--- + +# ADR 0014: The plugin keeps WP-CLI on a core that ships the API + +| | | +|---|---| +| **Number** | 0014 | +| **Date** | 2026-10-26 | +| **Status** | Accepted | + +## Context + +The Secrets API landed in core trunk as r64337, for 7.2. Core took `src/` and left the WP-CLI +commands out: WP-CLI commands ship as WP-CLI packages rather than in core, and the commit message +says the commands stay in the feature plugin. + +Until now the plugin's bootstrap went fully dark on a WordPress that ships the API. It loaded +nothing, and its admin notice said it could be deactivated. That made the commands disappear on +exactly the release the commit message promised them for. + +## Decision + +**On a WordPress that ships the API, the plugin registers `wp secret` and `wp network-secret` and +nothing else.** It loads none of `src/` or `plugin/`. The commands call the public API and the +internals core has as well (`_wp_secrets_get_key_manager()`, `_wp_secrets_get_provider()`, +`_wp_secrets_get_store()`, `WP_Secrets_Config_Key_Provider`), so the same `cli/` runs on core's +implementation unchanged. `wp secret health` loads core's +`wp-admin/includes/secrets-site-health.php` itself, since core loads it only for the Site Health +screen. + +**`migrate-legacy` does not come along.** The prototype was deprecated and its users told to +migrate more than a month before 7.2. The migrator and legacy reader load only with the plugin's +own copy. On core, the subcommand stays registered, because WP-CLI registers every public method, +but it exits 1 with a message saying to migrate before upgrading. + +**A conflict still registers nothing.** When something other than core declared +`wp_get_secret()`, the commands would run against an unknown credential store, so they stay off. + +## Consequences + +- The admin notice on a WordPress that ships the API says the plugin now provides only its WP-CLI + commands, and can be deactivated by anyone who does not use them. +- `cli/` must keep calling nothing that only the plugin's copy defines. The CLI tests and the smoke + test run against trunk in CI, which catches a call that would break there. +- The read-time prototype fallback store is gone on 7.2. A site still holding prototype rows + has to run `migrate-legacy` on 6.8–7.1 before upgrading. diff --git a/docs/reference/functions.md b/docs/reference/functions.md index 44c44a9..6503ad9 100644 --- a/docs/reference/functions.md +++ b/docs/reference/functions.md @@ -321,6 +321,24 @@ function wp_secrets_api_notice_superseded(): void **Source:** [`secrets-api.php`](../../secrets-api.php) +## `wp_secrets_api_register_cli()` + +Registers `wp secret` and `wp network-secret`. + +Called under WP-CLI from both the branch that loads the plugin's own copy of +the API and the branch where core ships it. cli/ is never copied to core, and +every call it makes is to the public API or to internals core has too, so the +same commands run on either implementation. The one exception is +`migrate-legacy`, whose migrator loads only with the plugin's copy. + +```php +function wp_secrets_api_register_cli(): void +``` + +**Returns:** `void` + +**Source:** [`secrets-api.php`](../../secrets-api.php) + ## `wp_secrets_api_uninstall()` Removes the capability this plugin granted, on uninstall – not on deactivation. Deactivating and reactivating the plugin must not silently strip a capability an administrator may have started relying on for something else in the meantime. diff --git a/docs/reference/wp-cli.md b/docs/reference/wp-cli.md index fd02b65..a313088 100644 --- a/docs/reference/wp-cli.md +++ b/docs/reference/wp-cli.md @@ -157,6 +157,9 @@ key is reported as skipped. There is no delete step. Removing a legacy option once the migration is verified is left to the operator, using wp option delete. +Not available on a WordPress that ships the Secrets API: the migrator loads +only with this plugin's own copy of the API. Migrate before upgrading. + ``` wp network-secret migrate-legacy [--dry-run] [--name=] [--map=] [--namespace=] [--format=] ``` @@ -396,6 +399,9 @@ key is reported as skipped. There is no delete step. Removing a legacy option once the migration is verified is left to the operator, using wp option delete. +Not available on a WordPress that ships the Secrets API: the migrator loads +only with this plugin's own copy of the API. Migrate before upgrading. + ``` wp secret migrate-legacy [--dry-run] [--name=] [--map=] [--namespace=] [--format=] ``` diff --git a/docs/spec/scope.md b/docs/spec/scope.md index 6c37473..3839a14 100644 --- a/docs/spec/scope.md +++ b/docs/spec/scope.md @@ -36,8 +36,8 @@ It copies `src/` unchanged, then adds the loading the plugin's bootstrap does no It also brings the tests for everything in `src/`. -**Standing down.** `wp_secrets_api_bootstrap()` in `secrets-api.php` loads nothing and shows an -admin notice when `$wp_version` is at least `WP_SECRETS_API_CORE_VERSION` (`7.2`, overridable) +**Standing down.** `wp_secrets_api_bootstrap()` in `secrets-api.php` loads none of `src/` or +`plugin/`, registers only the WP-CLI commands, and shows an admin notice when `$wp_version` is at least `WP_SECRETS_API_CORE_VERSION` (`7.2`, overridable) **and** `wp_get_secret()` already exists. A beta, RC, or trunk build counts as its release, so `7.2-beta1-64606-src` passes the version test. If the symbol exists but the version test fails, the plugin refuses to load and shows a conflict notice instead of deferring to an unknown @@ -63,8 +63,9 @@ the named surface, all in `src/wp-includes/secrets.php` unless noted: `wp secret` with `set`, `get`, `delete`, `list`, `retire`, `import-option`, `migrate-legacy`, `rotate`, `generate-key`, `health`, and `dropin`. `WP_CLI_Secret_Network_Command` registers `wp network-secret` with the same subcommands for network scope. Both load only when `WP_CLI` is -defined and true. Both are exercised end to end against a real `wp` binary by -`tests/smoke/smoke.sh`, on single site and multisite. +defined and true. On a WordPress that ships the API they run on core's implementation, and +`migrate-legacy` refuses, since its migrator loads only with the plugin's copy. Both are exercised +end to end against a real `wp` binary by `tests/smoke/smoke.sh`, on single site and multisite. **Site Health.** Three tests (`secrets_api_key_source`, undecryptable secrets, `secrets_api_needs_rotation`) and a debug-information section, in @@ -91,6 +92,11 @@ without the API, stranding every site relying on it. The proposal's own timeline deferral to 7.3. `docs/decisions/` does not record this separately; the reasoning lives in the bootstrap's comments. +**Why WP-CLI stays in the plugin.** The proposal puts WP-CLI support in 7.2. Core took the API +and left the commands out, since WP-CLI commands ship as WP-CLI packages, so the plugin keeps +providing them on 7.2. See +[ADR 0014](../decisions/0014-the-plugin-keeps-wp-cli-on-a-core-that-ships-the-api.md). + **`migrate-legacy` is outside the proposal.** It, and the read-time upgrade behind it, exist for sites that adopted an earlier prototype. Neither is core-bound. See [import.md](import.md). diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 48c1a5a..4db6a1f 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -52,7 +52,7 @@ - + diff --git a/secrets-api.php b/secrets-api.php index 8e985ee..b973f99 100644 --- a/secrets-api.php +++ b/secrets-api.php @@ -67,10 +67,19 @@ function wp_secrets_api_bootstrap(): void { // version_compare() alone ranks every pre-release below '7.2'. $release = explode( '-', $wp_version )[0]; - // Core ships the API. Load nothing; the plugin is redundant. + /* + * Core ships the API. Load nothing of src/ or plugin/: core's implementation + * serves every call. The WP-CLI commands are the exception, because core leaves + * WP-CLI commands to WP-CLI packages, so this plugin still provides them, running + * on core's functions and classes. + */ if ( version_compare( $release, WP_SECRETS_API_CORE_VERSION, '>=' ) && $symbol_taken ) { add_action( 'admin_notices', 'wp_secrets_api_notice_superseded' ); + if ( defined( 'WP_CLI' ) && WP_CLI ) { + wp_secrets_api_register_cli(); + } + return; } @@ -205,14 +214,29 @@ function wp_secrets_api_bootstrap(): void { */ require_once WP_SECRETS_API_PLUGIN_DIR . 'plugin/class-secrets-api-migrator.php'; - require_once WP_SECRETS_API_PLUGIN_DIR . 'cli/class-wp-cli-secret-command.php'; - require_once WP_SECRETS_API_PLUGIN_DIR . 'cli/class-wp-cli-secret-network-command.php'; - - WP_CLI::add_command( 'secret', 'WP_CLI_Secret_Command' ); - WP_CLI::add_command( 'network-secret', 'WP_CLI_Secret_Network_Command' ); + wp_secrets_api_register_cli(); } } +/** + * Registers `wp secret` and `wp network-secret`. + * + * Called under WP-CLI from both the branch that loads the plugin's own copy of + * the API and the branch where core ships it. cli/ is never copied to core, and + * every call it makes is to the public API or to internals core has too, so the + * same commands run on either implementation. The one exception is + * `migrate-legacy`, whose migrator loads only with the plugin's copy. + * + * @return void + */ +function wp_secrets_api_register_cli(): void { + require_once WP_SECRETS_API_PLUGIN_DIR . 'cli/class-wp-cli-secret-command.php'; + require_once WP_SECRETS_API_PLUGIN_DIR . 'cli/class-wp-cli-secret-network-command.php'; + + WP_CLI::add_command( 'secret', 'WP_CLI_Secret_Command' ); + WP_CLI::add_command( 'network-secret', 'WP_CLI_Secret_Network_Command' ); +} + /** * Admin notice shown when core supersedes this plugin. * @@ -224,7 +248,7 @@ function wp_secrets_api_notice_superseded(): void { } wp_admin_notice( - esc_html__( 'This version of WordPress provides the Secrets API natively. The Secrets API feature plugin is no longer doing anything and can be deactivated.', 'secrets-api' ), + esc_html__( 'This version of WordPress provides the Secrets API natively. The Secrets API feature plugin now provides only its WP-CLI commands, wp secret and wp network-secret, and can be deactivated if you do not use them.', 'secrets-api' ), array( 'type' => 'info' ) ); } diff --git a/tests/includes/class-mock-wp-cli.php b/tests/includes/class-mock-wp-cli.php index f7b056a..be34183 100644 --- a/tests/includes/class-mock-wp-cli.php +++ b/tests/includes/class-mock-wp-cli.php @@ -49,6 +49,7 @@ class WP_CLI { public static $errors = array(); public static $confirm_response = true; public static $formatted_items = array(); + public static $commands = array(); public static function reset() { self::$log = array(); @@ -57,6 +58,7 @@ public static function reset() { self::$errors = array(); self::$confirm_response = true; self::$formatted_items = array(); + self::$commands = array(); } public static function log( $message ) { @@ -118,11 +120,14 @@ public static function confirm( $question, $assoc_args = array() ) { } /** - * @param string $name Command name. Ignored by this test double. - * @param string $command_class Command class name. Ignored by this test double. + * Records the registration. This test double never dispatches real + * subcommands; tests call the command class's methods directly. + * + * @param string $name Command name. + * @param string $command_class Command class name. */ public static function add_command( $name, $command_class ) { - // No-op: this test double never dispatches real subcommands. + self::$commands[ $name ] = $command_class; } } } diff --git a/tests/includes/trait-secrets-api-plugin-layer-tests.php b/tests/includes/trait-secrets-api-plugin-layer-tests.php index d64ac3a..6cd6345 100644 --- a/tests/includes/trait-secrets-api-plugin-layer-tests.php +++ b/tests/includes/trait-secrets-api-plugin-layer-tests.php @@ -1,40 +1,43 @@ getFileName(); + + return false !== $declared_in && realpath( $declared_in ) === realpath( ABSPATH . WPINC . '/secrets.php' ); +} + +/** + * Skips the whole test case when the plugin has stood down for core. + */ trait Secrets_API_Plugin_Layer_Tests { public static function set_up_before_class(): void { - if ( self::core_provides_the_secrets_api() ) { + if ( secrets_api_tests_core_ships_the_api() ) { self::markTestSkipped( 'This WordPress ships the Secrets API, so the plugin stands down and loads no plugin-only layer.' ); } parent::set_up_before_class(); } - - /** - * Whether wp_get_secret() was declared by core's wp-includes/secrets.php - * rather than by the plugin's copy in src/. - * - * @return bool - */ - private static function core_provides_the_secrets_api(): bool { - if ( ! function_exists( 'wp_get_secret' ) ) { - return false; - } - - $declared_in = ( new ReflectionFunction( 'wp_get_secret' ) )->getFileName(); - - return false !== $declared_in && realpath( $declared_in ) === realpath( ABSPATH . WPINC . '/secrets.php' ); - } } diff --git a/tests/phpunit/test-secrets-noop-gate.php b/tests/phpunit/test-secrets-noop-gate.php index c0682a3..16a8e04 100644 --- a/tests/phpunit/test-secrets-noop-gate.php +++ b/tests/phpunit/test-secrets-noop-gate.php @@ -48,7 +48,7 @@ public function test_the_api_symbol_is_already_declared_in_this_process() { // – core ships the API --------------------------------------------------- - public function test_core_providing_the_api_is_a_silent_no_op_with_an_info_notice() { + public function test_core_providing_the_api_loads_nothing_and_shows_an_info_notice() { $GLOBALS['wp_version'] = WP_SECRETS_API_CORE_VERSION; wp_secrets_api_bootstrap(); @@ -57,6 +57,26 @@ public function test_core_providing_the_api_is_a_silent_no_op_with_an_info_notic $this->assertFalse( has_action( 'admin_notices', 'wp_secrets_api_notice_conflict' ) ); } + /** + * Core leaves WP-CLI commands to WP-CLI packages, so the plugin keeps + * providing `wp secret` and `wp network-secret`, running on core's + * implementation. + */ + public function test_core_providing_the_api_still_registers_the_wp_cli_commands() { + $GLOBALS['wp_version'] = WP_SECRETS_API_CORE_VERSION; + WP_CLI::reset(); + + wp_secrets_api_bootstrap(); + + $this->assertSame( + array( + 'secret' => 'WP_CLI_Secret_Command', + 'network-secret' => 'WP_CLI_Secret_Network_Command', + ), + WP_CLI::$commands + ); + } + public function test_a_wordpress_newer_than_the_core_version_also_no_ops() { $GLOBALS['wp_version'] = '9.9'; @@ -104,6 +124,19 @@ public function test_below_the_core_version_a_taken_symbol_is_a_conflict() { $this->assertFalse( has_action( 'admin_notices', 'wp_secrets_api_notice_superseded' ) ); } + /** + * A conflict defers to nothing, the WP-CLI commands included: they would run + * on an unknown implementation of a credential store. + */ + public function test_a_conflict_registers_no_wp_cli_commands() { + $GLOBALS['wp_version'] = '6.8'; + WP_CLI::reset(); + + wp_secrets_api_bootstrap(); + + $this->assertSame( array(), WP_CLI::$commands ); + } + /** * Both no-op branches must return before reaching the rest of the bootstrap. * The capability filter is the last thing that bootstrap registers before the diff --git a/tests/phpunit/test-wp-cli-secret-command.php b/tests/phpunit/test-wp-cli-secret-command.php index cb37455..5d38c28 100644 --- a/tests/phpunit/test-wp-cli-secret-command.php +++ b/tests/phpunit/test-wp-cli-secret-command.php @@ -7,8 +7,6 @@ */ class Tests_Secrets_WPCLISecretCommand extends WP_UnitTestCase { - use Secrets_API_Plugin_Layer_Tests; - public function set_up() { parent::set_up(); @@ -19,6 +17,15 @@ private function command() { return new WP_CLI_Secret_Command(); } + /** + * The migrator loads only with the plugin's own copy of the API. + */ + private function require_the_migrator(): void { + if ( secrets_api_tests_core_ships_the_api() ) { + $this->markTestSkipped( 'This WordPress ships the Secrets API, so migrate-legacy is not available.' ); + } + } + // – set -------------------------------------------------------------- /** @@ -495,7 +502,24 @@ public function test_migrate_legacy_is_refused_for_network_scope() { ( new WP_CLI_Secret_Network_Command() )->migrate_legacy( array(), array() ); } + public function test_migrate_legacy_is_refused_on_a_wordpress_that_ships_the_api() { + if ( ! secrets_api_tests_core_ships_the_api() ) { + $this->markTestSkipped( 'Needs a WordPress that ships the Secrets API.' ); + } + + try { + $this->command()->migrate_legacy( array(), array() ); + $this->fail( 'Expected migrate-legacy to refuse.' ); + } catch ( Mock_WP_CLI_Exit_Exception $e ) { + $this->assertSame( 1, $e->get_exit_code() ); + } + + $this->assertStringContainsString( 'ships the Secrets API', WP_CLI::$errors[0] ); + } + public function test_migrate_legacy_reports_migrated_status() { + $this->require_the_migrator(); + $this->setExpectedIncorrectUsage( 'wp_secrets_validate_name' ); ( new Legacy_Fixture_Writer() )->write_secret( 'api_key', 'value' ); @@ -508,6 +532,8 @@ public function test_migrate_legacy_reports_migrated_status() { } public function test_migrate_legacy_map_overrides_the_derived_name() { + $this->require_the_migrator(); + ( new Legacy_Fixture_Writer() )->write_secret( 'API_Key', 'value' ); $this->command()->migrate_legacy( array(), array( 'map' => 'API_Key:myplugin/api-key' ) ); @@ -518,6 +544,8 @@ public function test_migrate_legacy_map_overrides_the_derived_name() { } public function test_migrate_legacy_warns_when_vendor_class_detected() { + $this->require_the_migrator(); + $this->setExpectedIncorrectUsage( 'wp_secrets_validate_name' ); ( new Legacy_Fixture_Writer() )->write_secret( 'api_key', 'value' ); @@ -533,6 +561,8 @@ public function test_migrate_legacy_warns_when_vendor_class_detected() { * @preserveGlobalState disabled */ public function test_migrate_legacy_warns_and_still_migrates_when_vendor_class_is_present() { + $this->require_the_migrator(); + $this->setExpectedIncorrectUsage( 'wp_secrets_validate_name' ); eval( 'namespace WordPress\AI\Vendor\Secrets; class Secrets_Manager {}' ); // phpcs:ignore Squiz.PHP.Eval.Discouraged -- only way to define a class under a namespace conditionally, for this one test. @@ -550,6 +580,8 @@ public function test_migrate_legacy_warns_and_still_migrates_when_vendor_class_i public function test_migrate_legacy_halts_with_exit_code_1_on_any_error_entry() { + $this->require_the_migrator(); + $this->setExpectedIncorrectUsage( 'wp_secrets_validate_name' ); $writer = new Legacy_Fixture_Writer(); @@ -565,6 +597,8 @@ public function test_migrate_legacy_halts_with_exit_code_1_on_any_error_entry() } public function test_migrate_legacy_never_logs_a_plaintext() { + $this->require_the_migrator(); + $this->setExpectedIncorrectUsage( 'wp_secrets_validate_name' ); ( new Legacy_Fixture_Writer() )->write_secret( 'api_key', 'UNIQUE-CLI-PLAINTEXT-CANARY-7c2e' ); diff --git a/tests/phpunit/test-wp-cli-secret-network-command.php b/tests/phpunit/test-wp-cli-secret-network-command.php index f086ec2..ba7264b 100644 --- a/tests/phpunit/test-wp-cli-secret-network-command.php +++ b/tests/phpunit/test-wp-cli-secret-network-command.php @@ -6,8 +6,6 @@ */ class Tests_Secrets_WPCLISecretNetworkCommand extends WP_UnitTestCase { - use Secrets_API_Plugin_Layer_Tests; - public function set_up() { parent::set_up(); diff --git a/tests/smoke/SPEC.md b/tests/smoke/SPEC.md index 8cdbd18..f5bb43c 100644 --- a/tests/smoke/SPEC.md +++ b/tests/smoke/SPEC.md @@ -80,7 +80,9 @@ Every case below checks the exit code as well as the output. - `generate-key` prints 44 characters that decode to exactly 32 bytes. - `health --format=json` parses as JSON. `dropin` reports no drop-in. - `import-option` moves a seeded option into a secret, and `get --reveal` matches it. -- `migrate-legacy --dry-run` exits 0 on an install with no prototype rows. +- `migrate-legacy --dry-run` exits 0 on an install with no prototype rows. On a WordPress that ships + the API it exits 1 and says why, since its migrator loads only with the plugin's copy + ([ADR 0014](../../docs/decisions/0014-the-plugin-keeps-wp-cli-on-a-core-that-ships-the-api.md)). ### C. Rotation, end to end diff --git a/tests/smoke/smoke.sh b/tests/smoke/smoke.sh index a3a4a58..4b2036e 100755 --- a/tests/smoke/smoke.sh +++ b/tests/smoke/smoke.sh @@ -479,9 +479,15 @@ case_b_behaviour() { run "${WP[@]}" option delete "$opt_name" assert_status 0 "option delete $opt_name exits 0" - # 8. migrate-legacy. + # 8. migrate-legacy. On a WordPress that ships the API its migrator is not + # loaded, and it refuses rather than fataling. run "${WP[@]}" secret migrate-legacy --dry-run - assert_status 0 "migrate-legacy --dry-run exits 0 with no prototype rows" + if [ "$CORE_SHIPS_API" = 1 ]; then + assert_status 1 "migrate-legacy refuses on a WordPress that ships the API" + assert_err_contains "ships the Secrets API" "migrate-legacy's refusal explains why" + else + assert_status 0 "migrate-legacy --dry-run exits 0 with no prototype rows" + fi # 9. single-site refusal (case E's last bullet, checked here). run "${WP[@]}" network-secret get "${NS}/anything" @@ -716,7 +722,21 @@ case_e_multisite() { # --- main --- +# Whether this WordPress ships the Secrets API (trunk since r64337). The plugin +# then loads none of its own copy, and the commands run on core's +# implementation: same assertions, except migrate-legacy. +detect_core_api() { + CORE_SHIPS_API=$("${WP[@]}" eval 'echo realpath( ( new ReflectionFunction( "wp_get_secret" ) )->getFileName() ) === realpath( ABSPATH . WPINC . "/secrets.php" ) ? 1 : 0;' 2>/dev/null) + if [ "$CORE_SHIPS_API" = 1 ]; then + diag "WordPress ships the Secrets API: the commands run on core's implementation" + else + CORE_SHIPS_API=0 + diag "WordPress does not ship the Secrets API: the commands run on the plugin's copy" + fi +} + main() { + detect_core_api case_a_registration case_b_behaviour case_c_rotation