From 54d00b8a127d60d269efc8fed5eca8f4ca059c8c Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 29 Sep 2026 22:59:16 +0300 Subject: [PATCH 1/4] fix: a generic plugin update keeps the plugin active Plugin_Upgrader::upgrade() deactivates its target at upgrader_pre_install outside cron, and only wp-admin's skin turns it back on. Every plugin updated through /aura/v1/update/plugin, the v2 batch or update_plugin_safely came back inactive; for SiteAgent itself that removed every aura/* route. - One helper, upgrade_plugin_keeping_activation(), is the only generic plugin upgrade call. It records is_plugin_active / network activation before the upgrade and re-activates silently in a finally (success, failure, throw), never activating a plugin that was inactive; it reports `reactivated` and `reactivation_error`. - SiteAgent's own file is refused on the generic paths with aura_use_self_update (REST 409) after the multisite, host and busy-claim refusals; the upgrader is never reached. A batch refuses only that entry. - upgrade() null is aura_update_failed with the skin's reason (never "No update available"); false is aura_no_update_offered (REST 409); a WP_Error keeps its code and message. - A missing update_plugins offer is refreshed once with wp_update_plugins() before the upgrade; `offered_version` is reported when offered. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-aura-worker-api.php | 10 +- .../includes/class-aura-worker-updater.php | 210 ++++++++++++---- tests/bootstrap.php | 67 ++++- tests/unit/InstallLedgerSiteAgentTest.php | 5 +- tests/unit/RestUpdatePluginStatusTest.php | 82 ++++++ tests/unit/SelfUpdateRecoveryTest.php | 30 +-- .../UpdatePluginPreservesActivationTest.php | 235 ++++++++++++++++++ tests/unit/UpdatePluginResultMappingTest.php | 146 +++++++++++ tests/unit/UpdatePluginSelfRefusalTest.php | 99 ++++++++ 9 files changed, 817 insertions(+), 67 deletions(-) create mode 100644 tests/unit/RestUpdatePluginStatusTest.php create mode 100644 tests/unit/UpdatePluginPreservesActivationTest.php create mode 100644 tests/unit/UpdatePluginResultMappingTest.php create mode 100644 tests/unit/UpdatePluginSelfRefusalTest.php diff --git a/digitizer-site-worker/includes/class-aura-worker-api.php b/digitizer-site-worker/includes/class-aura-worker-api.php index d0123a93..703b4240 100644 --- a/digitizer-site-worker/includes/class-aura-worker-api.php +++ b/digitizer-site-worker/includes/class-aura-worker-api.php @@ -747,8 +747,16 @@ public function update_plugin( $request ) { ) ), 404 ); } + // 409, not 500, for the two outcomes that are not a broken site + // (2.23.1): nothing is offered to install (`aura_no_update_offered`), + // and SiteAgent's own file, which updates only through POST + // /aura/v1/self-update (`aura_use_self_update`). $result = $this->updater->update_plugin( $plugin_file ); - $status = $result['success'] ? 200 : 500; + $status = 200; + if ( ! $result['success'] ) { + $code = isset( $result['code'] ) ? (string) $result['code'] : ''; + $status = in_array( $code, array( 'aura_no_update_offered', 'aura_use_self_update' ), true ) ? 409 : 500; + } return new WP_REST_Response( Aura_Worker_Rules::with_warnings( $result ), $status ); } diff --git a/digitizer-site-worker/includes/class-aura-worker-updater.php b/digitizer-site-worker/includes/class-aura-worker-updater.php index 942295d4..98bbe1b8 100644 --- a/digitizer-site-worker/includes/class-aura-worker-updater.php +++ b/digitizer-site-worker/includes/class-aura-worker-updater.php @@ -1497,9 +1497,19 @@ protected function batch_update_one( $plugin_file, $rollback, $health, $create_b $update_result = $this->heartbeat_during( $fence, function () use ( $plugin_file ) { return $this->update_single_plugin( $plugin_file ); } ); + // 2.23.1: whether the plugin had to be turned back on, on success and + // failure alike. + foreach ( array( 'reactivated', 'reactivation_error' ) as $key ) { + if ( array_key_exists( $key, $update_result ) ) { + $entry[ $key ] = $update_result[ $key ]; + } + } if ( ! $update_result['success'] ) { $entry['status'] = 'failed'; $entry['detail'] = $update_result['error']; + if ( isset( $update_result['code'] ) ) { + $entry['code'] = (string) $update_result['code']; + } return $entry; } @@ -1685,24 +1695,25 @@ private function self_update_busy() { /** * Update a specific plugin. * + * Never SiteAgent itself (2.23.1): that file answers `aura_use_self_update` + * after the multisite, host and busy-claim refusals, and the upgrader is + * never reached — see upgrade_plugin_keeping_activation(). + * * @param string $plugin_file Plugin file path (e.g., "akismet/akismet.php"). - * @return array Result with success status and message. + * @return array Result with success status and message; see + * upgrade_plugin_keeping_activation() for the keys. */ public function update_plugin( $plugin_file ) { $this->load_upgrade_dependencies(); - // This plugin's own update goes under the self-update claim, like every - // other path that can replace these files (Codex round-23 P1): a generic - // update landing between a self-update's backup, install and probe would - // have the beacon describing one build and the rollback restoring another. + // This plugin's own file still goes under the self-update claim, like + // every other path that can replace these files (Codex round-23 P1), so + // the multisite and busy refusals keep their precedence over the + // aura_use_self_update refusal the work answers. $lost = false; $result = $this->guarding_self( $plugin_file, function ( $fence ) use ( $plugin_file, &$lost ) { $r = $this->heartbeat_during( $fence, function () use ( $plugin_file ) { - $skin = new Automatic_Upgrader_Skin(); - $upgrader = new Plugin_Upgrader( $skin ); - return Aura_Worker_Install_Ledger::as_siteagent( function () use ( $upgrader, $plugin_file ) { - return $upgrader->upgrade( $plugin_file ); - }, $upgrader ); + return $this->upgrade_plugin_keeping_activation( $plugin_file ); } ); // A claim lost during the phase (a heartbeat that fired only at // post-install passes through) is a successor owning these files: @@ -1720,31 +1731,160 @@ public function update_plugin( $plugin_file ) { return $this->self_update_busy(); } - if ( is_wp_error( $result ) ) { - return array( - 'success' => false, - 'error' => $result->get_error_message(), - ); + if ( $result['success'] ) { + $result['message'] = __( 'Plugin updated successfully.', 'digitizer-site-worker' ); } + return $result; + } - if ( false === $result ) { + /** + * The one place a generic plugin update calls Plugin_Upgrader::upgrade() + * (2.23.1) — the single update and every batch entry (and so + * `update_plugin_safely`) come through here. + * + * 1. SiteAgent's own file is refused (`aura_use_self_update`): upgrade() + * deactivates its target at pre_install, and a deactivated SiteAgent + * has no routes left for Aura to repair it with. It updates itself only + * through `POST /aura/v1/self-update`. + * 2. A plugin with no entry in `update_plugins->response` gets one + * `wp_update_plugins()` and a re-read before the upgrade: the entries + * for GitHub-hosted plugins are injected when the transient is read, by + * code that may not be loaded now. + * 3. The activation state is recorded before the upgrade and restored in a + * `finally` — on success, on failure and on a throw. Core's + * `deactivate_plugin_before_upgrade` deactivates the target outside + * cron, and only wp-admin's skin turns it back on. A plugin that was + * inactive is never activated. + * 4. The result is mapped: `false` is "not offered" + * (`aura_no_update_offered`); `null` is run() failing + * (`aura_update_failed`, with the skin's reason — core discards the + * WP_Error); a WP_Error keeps its code and message. + * + * @param string $plugin_file Plugin file path. + * @return array { success, error?, code?, reactivated?, reactivation_error?, offered_version? } + */ + private function upgrade_plugin_keeping_activation( $plugin_file ) { + if ( self::SELF_PLUGIN_FILE === $plugin_file ) { return array( 'success' => false, - 'error' => __( 'Update failed. The plugin may not have an update available.', 'digitizer-site-worker' ), + 'code' => 'aura_use_self_update', + 'error' => __( 'SiteAgent cannot update itself through the generic plugin update: it would be deactivated mid-update and lose its own routes. Use POST /aura/v1/self-update.', 'digitizer-site-worker' ), ); } - if ( null === $result ) { - return array( + $offer = $this->offered_update( $plugin_file ); + if ( null === $offer ) { + wp_update_plugins(); + $offer = $this->offered_update( $plugin_file ); + } + + $was_network = function_exists( 'is_multisite' ) && is_multisite() && is_plugin_active_for_network( $plugin_file ); + $was_active = $was_network || is_plugin_active( $plugin_file ); + $reactivated = false; + $react_error = null; + + $skin = new Automatic_Upgrader_Skin(); + $upgrader = new Plugin_Upgrader( $skin ); + try { + $result = Aura_Worker_Install_Ledger::as_siteagent( function () use ( $upgrader, $plugin_file ) { + return $upgrader->upgrade( $plugin_file ); + }, $upgrader ); + } finally { + if ( $was_active ) { + $active_now = $was_network ? is_plugin_active_for_network( $plugin_file ) : is_plugin_active( $plugin_file ); + if ( ! $active_now ) { + $activated = activate_plugin( $plugin_file, '', $was_network, true ); + if ( is_wp_error( $activated ) ) { + $react_error = $activated->get_error_message(); + } else { + $reactivated = true; + } + } + } + } + + if ( is_wp_error( $result ) ) { + $out = array( + 'success' => false, + 'code' => (string) $result->get_error_code(), + 'error' => $result->get_error_message(), + ); + } elseif ( false === $result ) { + $out = array( 'success' => false, - 'error' => __( 'No update available for this plugin.', 'digitizer-site-worker' ), + 'code' => 'aura_no_update_offered', + 'error' => __( 'No update is offered for this plugin: WordPress\'s update list has no entry for it, even after a refresh. Nothing was changed.', 'digitizer-site-worker' ), ); + } elseif ( null === $result ) { + $reason = $this->upgrade_failure_reason( $skin ); + $out = array( + 'success' => false, + 'code' => 'aura_update_failed', + 'error' => '' !== $reason + /* translators: %s: the upgrader's own messages */ + ? sprintf( __( 'Update failed: %s', 'digitizer-site-worker' ), $reason ) + : __( 'Update failed: the upgrader gave no reason.', 'digitizer-site-worker' ), + ); + } else { + $out = array( 'success' => true ); } - return array( - 'success' => true, - 'message' => __( 'Plugin updated successfully.', 'digitizer-site-worker' ), - ); + $out['reactivated'] = $reactivated; + if ( null !== $react_error ) { + $out['reactivation_error'] = $react_error; + } + if ( null !== $offer && isset( $offer->new_version ) ) { + $out['offered_version'] = (string) $offer->new_version; + } + return $out; + } + + /** + * The `update_plugins` entry offering $plugin_file, or null. + * + * @param string $plugin_file Plugin file path. + * @return object|null + */ + private function offered_update( $plugin_file ) { + $transient = get_site_transient( 'update_plugins' ); + if ( ! is_object( $transient ) || empty( $transient->response ) || ! is_array( $transient->response ) ) { + return null; + } + $entry = isset( $transient->response[ $plugin_file ] ) ? $transient->response[ $plugin_file ] : null; + return is_object( $entry ) ? $entry : null; + } + + /** + * Why a run() failed, from what the skin collected: `get_errors()` where + * the skin has it, then the last of `get_upgrade_messages()` (core's + * Automatic_Upgrader_Skin feeds a WP_Error's message into those). + * + * @param object $skin The upgrader skin. + * @return string '' when the skin kept nothing. + */ + private function upgrade_failure_reason( $skin ) { + $parts = array(); + if ( method_exists( $skin, 'get_errors' ) ) { + $errors = $skin->get_errors(); + if ( is_wp_error( $errors ) ) { + foreach ( $errors->get_error_messages() as $m ) { + $parts[] = $m; + } + } + } + if ( method_exists( $skin, 'get_upgrade_messages' ) ) { + foreach ( array_slice( (array) $skin->get_upgrade_messages(), -3 ) as $m ) { + $parts[] = $m; + } + } + $clean = array(); + foreach ( $parts as $m ) { + $m = trim( html_entity_decode( wp_strip_all_tags( (string) $m ), ENT_QUOTES, 'UTF-8' ) ); + if ( '' !== $m && ! in_array( $m, $clean, true ) ) { + $clean[] = $m; + } + } + return implode( ' ', $clean ); } /** @@ -1867,29 +2007,15 @@ public function update_translations() { } /** - * Update a single plugin using Plugin_Upgrader. + * Update a single plugin (a batch entry) using Plugin_Upgrader, through + * upgrade_plugin_keeping_activation() — SiteAgent's own file is refused. * * @param string $plugin_file Plugin file path (e.g., "akismet/akismet.php"). - * @return array { success: bool, error?: string } + * @return array { success: bool, error?: string, code?: string, reactivated?: bool, ... } */ protected function update_single_plugin( $plugin_file ) { $this->load_upgrade_dependencies(); - - $skin = new Automatic_Upgrader_Skin(); - $upgrader = new Plugin_Upgrader( $skin ); - $result = Aura_Worker_Install_Ledger::as_siteagent( function () use ( $upgrader, $plugin_file ) { - return $upgrader->upgrade( $plugin_file ); - }, $upgrader ); - - if ( is_wp_error( $result ) ) { - return array( 'success' => false, 'error' => $result->get_error_message() ); - } - - if ( false === $result || null === $result ) { - return array( 'success' => false, 'error' => __( 'Update failed or no update available.', 'digitizer-site-worker' ) ); - } - - return array( 'success' => true ); + return $this->upgrade_plugin_keeping_activation( $plugin_file ); } /** diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 766e44af..ea62a140 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1600,8 +1600,10 @@ function get_plugins() { } if ( ! function_exists( 'is_plugin_active' ) ) { + // Core's own rule: active on this site, OR network-active on a multisite. function is_plugin_active( $plugin ) { - return isset( $GLOBALS['_active_plugins'][ $plugin ] ); + return isset( $GLOBALS['_active_plugins'][ $plugin ] ) + || ( is_multisite() && isset( $GLOBALS['_network_active_plugins'][ $plugin ] ) ); } } @@ -1616,9 +1618,45 @@ function is_plugin_active_for_network( $plugin ) { } if ( ! function_exists( 'activate_plugin' ) ) { - function activate_plugin( $plugin ) { - $GLOBALS['_active_plugins'][ $plugin ] = true; - $GLOBALS['_mutations'][] = 'activate_plugin'; + // Core's signature. Every call is recorded in `_activate_plugin_calls` + // (plugin, redirect, network_wide, silent); a WP_Error in + // `_activate_plugin_result` is returned and nothing is activated (2.23.1). + function activate_plugin( $plugin, $redirect = '', $network_wide = false, $silent = false ) { + $GLOBALS['_activate_plugin_calls'][] = array( + 'plugin' => $plugin, + 'redirect' => $redirect, + 'network_wide' => $network_wide, + 'silent' => $silent, + ); + $GLOBALS['_mutations'][] = 'activate_plugin'; + if ( isset( $GLOBALS['_activate_plugin_result'] ) && is_wp_error( $GLOBALS['_activate_plugin_result'] ) ) { + return $GLOBALS['_activate_plugin_result']; + } + if ( $network_wide ) { + $GLOBALS['_network_active_plugins'][ $plugin ] = true; + } else { + $GLOBALS['_active_plugins'][ $plugin ] = true; + } + return null; + } +} + +if ( ! function_exists( 'get_site_transient' ) ) { + // `_site_transients` store (2.23.1: the updater reads `update_plugins`). + function get_site_transient( $key ) { + return isset( $GLOBALS['_site_transients'][ $key ] ) ? $GLOBALS['_site_transients'][ $key ] : false; + } +} + +if ( ! function_exists( 'wp_update_plugins' ) ) { + // Logged in `_updater_calls` (beside Plugin_Upgrader::upgrade, so a test + // can assert the order); `_wp_update_plugins_effect` models what the + // check writes into the `update_plugins` site transient. + function wp_update_plugins( $extra_stats = array() ) { + $GLOBALS['_updater_calls'][] = 'wp_update_plugins'; + if ( isset( $GLOBALS['_wp_update_plugins_effect'] ) && is_callable( $GLOBALS['_wp_update_plugins_effect'] ) ) { + call_user_func( $GLOBALS['_wp_update_plugins_effect'] ); + } } } @@ -1731,8 +1769,9 @@ function wp_upgrade() { if ( ! class_exists( 'Automatic_Upgrader_Skin' ) ) { class Automatic_Upgrader_Skin { + // `_upgrade_messages`: what the skin collected during the run (2.23.1). public function get_upgrade_messages() { - return array(); + return isset( $GLOBALS['_upgrade_messages'] ) ? (array) $GLOBALS['_upgrade_messages'] : array(); } } } @@ -1742,13 +1781,16 @@ class Plugin_Upgrader { public function __construct( $skin = null ) {} public function upgrade( $plugin_file ) { - $GLOBALS['_mutations'][] = 'Plugin_Upgrader::upgrade'; + $GLOBALS['_mutations'][] = 'Plugin_Upgrader::upgrade'; + $GLOBALS['_updater_calls'][] = 'Plugin_Upgrader::upgrade:' . $plugin_file; // `_upgrade_effect` models what happens WHILE the upgrade runs (a - // claim seized mid-phase), the way `_install_effect` does for install(). + // claim seized mid-phase, core's pre_install deactivation), the way + // `_install_effect` does for install(). It may throw. if ( isset( $GLOBALS['_upgrade_effect'] ) && is_callable( $GLOBALS['_upgrade_effect'] ) ) { - call_user_func( $GLOBALS['_upgrade_effect'], $this ); + call_user_func( $GLOBALS['_upgrade_effect'], $this, $plugin_file ); } - return true; + // array_key_exists, not ??: null models run() failing (core's $result has no default). + return array_key_exists( '_upgrade_result', $GLOBALS ) ? $GLOBALS['_upgrade_result'] : true; } public function install( $package, $args = array() ) { @@ -5457,6 +5499,13 @@ function sa_reset_state(): void { $GLOBALS['_is_admin'] = false; // is_admin() — see the stub above. $GLOBALS['_is_multisite'] = false; $GLOBALS['_network_active_plugins'] = array(); // is_plugin_active_for_network() — see the stub above. + // The generic plugin update (2.23.1) — see the stubs above. + $GLOBALS['_active_plugins'] = array(); + $GLOBALS['_activate_plugin_calls'] = array(); + $GLOBALS['_site_transients'] = array(); + $GLOBALS['_updater_calls'] = array(); + $GLOBALS['_upgrade_messages'] = array(); + unset( $GLOBALS['_activate_plugin_result'], $GLOBALS['_upgrade_result'], $GLOBALS['_upgrade_effect'], $GLOBALS['_wp_update_plugins_effect'] ); $GLOBALS['_current_blog_id'] = 1; // get_current_blog_id() — core's own default on a single site. $GLOBALS['_main_site_id'] = 1; // is_main_site() — which blog of a network is the main one (Ruling P39). $GLOBALS['_site_options'] = array(); diff --git a/tests/unit/InstallLedgerSiteAgentTest.php b/tests/unit/InstallLedgerSiteAgentTest.php index 56c472e0..11335c4a 100644 --- a/tests/unit/InstallLedgerSiteAgentTest.php +++ b/tests/unit/InstallLedgerSiteAgentTest.php @@ -211,6 +211,9 @@ public function test_every_upgrader_call_in_the_updater_is_wrapped(): void { $this->assertSame( 0, preg_match( '/\}\s*(,\s*\$\w+\s*){0,2}\)\s*;/', substr( $before, $opened ) ), "an upgrader call in {$func}() runs outside as_siteagent()" ); $wrapped++; } - $this->assertSame( 4, $wrapped ); // self-update install, update_plugin, update_theme, update_single_plugin + // Self-update install, update_theme, and the one generic plugin upgrade + // (2.23.1: update_plugin and update_single_plugin both call + // upgrade_plugin_keeping_activation(), so their two call sites are one). + $this->assertSame( 3, $wrapped ); } } diff --git a/tests/unit/RestUpdatePluginStatusTest.php b/tests/unit/RestUpdatePluginStatusTest.php new file mode 100644 index 00000000..fd64295f --- /dev/null +++ b/tests/unit/RestUpdatePluginStatusTest.php @@ -0,0 +1,82 @@ + array( 'Name' => 'Akismet', 'Version' => '1.0' ), + Aura_Worker_Updater::SELF_PLUGIN_FILE => array( 'Name' => 'SiteAgent', 'Version' => '2.23.0' ), + ); + $this->api = new Aura_Worker_API( new Aura_Worker_Security() ); + } + + protected function tearDown(): void { + unset( $GLOBALS['_fs_method'], $GLOBALS['_installed_plugins'] ); + delete_option( Aura_Worker_Updater::SELF_UPDATE_LOCK ); + } + + private function call( string $plugin ): WP_REST_Response { + $req = new WP_REST_Request(); + $req->set_header( 'X-Aura-Token', 'tok' ); + $req->set_param( 'plugin', $plugin ); + $res = $this->api->update_plugin( $req ); + $this->assertInstanceOf( WP_REST_Response::class, $res, is_wp_error( $res ) ? $res->get_error_code() : '' ); + return $res; + } + + public function test_not_offered_is_409(): void { + $GLOBALS['_upgrade_result'] = false; + + $res = $this->call( 'akismet/akismet.php' ); + + $this->assertSame( 409, $res->get_status() ); + $this->assertSame( 'aura_no_update_offered', $res->get_data()['code'] ); + } + + public function test_siteagent_itself_is_409_and_points_to_self_update(): void { + $res = $this->call( Aura_Worker_Updater::SELF_PLUGIN_FILE ); + + $this->assertSame( 409, $res->get_status() ); + $this->assertSame( 'aura_use_self_update', $res->get_data()['code'] ); + $this->assertStringContainsString( 'POST /aura/v1/self-update', $res->get_data()['error'] ); + $this->assertNotContains( 'Plugin_Upgrader::upgrade', $GLOBALS['_mutations'] ); + } + + public function test_a_failed_run_is_500(): void { + $GLOBALS['_site_transients']['update_plugins'] = (object) array( + 'response' => array( 'akismet/akismet.php' => (object) array( 'new_version' => '2.0' ) ), + ); + $GLOBALS['_upgrade_result'] = null; + + $res = $this->call( 'akismet/akismet.php' ); + + $this->assertSame( 500, $res->get_status() ); + $this->assertSame( 'aura_update_failed', $res->get_data()['code'] ); + } + + public function test_success_is_200_and_carries_reactivated(): void { + $GLOBALS['_active_plugins']['akismet/akismet.php'] = true; + $GLOBALS['_upgrade_effect'] = static function () { + unset( $GLOBALS['_active_plugins']['akismet/akismet.php'] ); + }; + + $res = $this->call( 'akismet/akismet.php' ); + + $this->assertSame( 200, $res->get_status() ); + $this->assertTrue( $res->get_data()['reactivated'] ); + } +} diff --git a/tests/unit/SelfUpdateRecoveryTest.php b/tests/unit/SelfUpdateRecoveryTest.php index a5da84c0..7f8319ca 100644 --- a/tests/unit/SelfUpdateRecoveryTest.php +++ b/tests/unit/SelfUpdateRecoveryTest.php @@ -1307,8 +1307,12 @@ public function test_the_generic_single_update_of_siteagent_waits_on_the_same_cl } public function test_the_generic_single_update_of_siteagent_takes_and_releases_the_claim(): void { + // 2.23.1: the generic path refuses SiteAgent's own file under the claim + // (after the multisite and busy refusals) — it no longer succeeds — + // and still releases what it took. $res = ( new Aura_Worker_Updater() )->update_plugin( Aura_Worker_Updater::SELF_PLUGIN_FILE ); - $this->assertTrue( $res['success'] ); + $this->assertFalse( $res['success'] ); + $this->assertSame( 'aura_use_self_update', $res['code'] ); $this->assertNull( sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ), 'the generic path releases what it took' ); } @@ -1544,16 +1548,14 @@ protected function update_single_plugin( $plugin_file ) { $this->assertSame( array(), array_filter( $GLOBALS['_filters']['upgrader_clear_destination'] ?? array() ), 'the beat is removed after the phase' ); } - public function test_a_generic_single_update_that_loses_its_claim_during_the_phase_is_not_reported_as_success(): void { - // Codex #94 round-5 P2: update_plugin() had no check after its phase, - // so a claim seized after upgrader_pre_install (post-install passes - // through) came back as success while the successor owned the files. - $successor = ''; - $GLOBALS['_upgrade_effect'] = function () use ( &$successor ) { - $held = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); - $fence = substr( $held, 0, strpos( $held, '|' ) ); - update_option( Aura_Worker_Updater::SELF_UPDATE_LOCK, $fence . '|' . ( time() - 11 * MINUTE_IN_SECONDS ) ); - $successor = Aura_Worker_Magic_Link::take_claim( Aura_Worker_Updater::SELF_UPDATE_LOCK, 10 * MINUTE_IN_SECONDS ); + public function test_a_generic_single_update_of_siteagent_has_no_upgrade_phase_to_lose_its_claim_in(): void { + // Codex #94 round-5 P2 made update_plugin() check its claim after the + // upgrade phase, so a claim seized mid-phase was never reported as + // success. Since 2.23.1 the generic path refuses SiteAgent's own file + // before the upgrader: the phase that could lose the claim never runs. + $ran = false; + $GLOBALS['_upgrade_effect'] = function () use ( &$ran ) { + $ran = true; }; try { $res = ( new Aura_Worker_Updater() )->update_plugin( Aura_Worker_Updater::SELF_PLUGIN_FILE ); @@ -1561,10 +1563,10 @@ public function test_a_generic_single_update_that_loses_its_claim_during_the_pha unset( $GLOBALS['_upgrade_effect'] ); } - $this->assertNotSame( '', $successor ); + $this->assertFalse( $ran, 'the upgrader is never reached for SiteAgent' ); $this->assertFalse( $res['success'] ); - $this->assertTrue( $res['in_progress'], 'a successor owns the files; the outcome is its to report' ); - $this->assertStringStartsWith( $successor . '|', (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ) ); + $this->assertSame( 'aura_use_self_update', $res['code'] ); + $this->assertNull( sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ) ); } public function test_a_batch_entry_whose_claim_is_seized_during_the_health_probe_does_not_roll_back(): void { diff --git a/tests/unit/UpdatePluginPreservesActivationTest.php b/tests/unit/UpdatePluginPreservesActivationTest.php new file mode 100644 index 00000000..95bc0767 --- /dev/null +++ b/tests/unit/UpdatePluginPreservesActivationTest.php @@ -0,0 +1,235 @@ + array( self::PLUGIN => (object) array( 'new_version' => '2.0' ) ), + ); + } + + protected function tearDown(): void { + unset( $GLOBALS['_fs_method'] ); + } + + /** Core's pre_install step: silently deactivate the target (site and network). */ + private function deactivate_during_upgrade(): void { + $GLOBALS['_upgrade_effect'] = static function ( $upgrader, $plugin_file ) { + unset( $GLOBALS['_active_plugins'][ $plugin_file ], $GLOBALS['_network_active_plugins'][ $plugin_file ] ); + }; + } + + private function batch_entry( array $out ): array { + $this->assertCount( 1, $out['results'] ); + return $out['results'][0]; + } + + // ----- update_plugin() ----------------------------------------------------- + + public function test_an_active_plugin_whose_upgrade_succeeds_is_active_afterwards(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $this->deactivate_during_upgrade(); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertTrue( $res['success'] ); + $this->assertTrue( is_plugin_active( self::PLUGIN ), 'the plugin is active again after the update' ); + $this->assertTrue( $res['reactivated'] ); + $this->assertCount( 1, $GLOBALS['_activate_plugin_calls'] ); + $call = $GLOBALS['_activate_plugin_calls'][0]; + $this->assertSame( self::PLUGIN, $call['plugin'] ); + $this->assertFalse( $call['network_wide'] ); + $this->assertTrue( $call['silent'], 're-activation is silent: no activation hooks run' ); + } + + public function test_an_active_plugin_that_stayed_active_is_not_activated_again(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertTrue( $res['success'] ); + $this->assertFalse( $res['reactivated'] ); + $this->assertSame( array(), $GLOBALS['_activate_plugin_calls'] ); + } + + public function test_an_active_plugin_whose_upgrade_returns_null_is_reactivated(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $GLOBALS['_upgrade_result'] = null; + $this->deactivate_during_upgrade(); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertFalse( $res['success'] ); + $this->assertTrue( $res['reactivated'] ); + $this->assertTrue( is_plugin_active( self::PLUGIN ) ); + } + + public function test_an_active_plugin_whose_upgrade_returns_a_wp_error_is_reactivated(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $GLOBALS['_upgrade_result'] = new WP_Error( 'copy_failed', 'Could not copy file.' ); + $this->deactivate_during_upgrade(); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertFalse( $res['success'] ); + $this->assertTrue( $res['reactivated'] ); + $this->assertTrue( is_plugin_active( self::PLUGIN ) ); + } + + public function test_an_upgrade_that_throws_still_reactivates_the_plugin(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $GLOBALS['_upgrade_effect'] = static function ( $upgrader, $plugin_file ) { + unset( $GLOBALS['_active_plugins'][ $plugin_file ] ); + throw new RuntimeException( 'boom' ); + }; + + try { + ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + $this->fail( 'the exception propagates' ); + } catch ( RuntimeException $e ) { + $this->assertSame( 'boom', $e->getMessage() ); + } + $this->assertTrue( is_plugin_active( self::PLUGIN ), 're-activated in the finally, before the exception left' ); + $this->assertCount( 1, $GLOBALS['_activate_plugin_calls'] ); + } + + public function test_an_inactive_plugin_stays_inactive(): void { + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertTrue( $res['success'] ); + $this->assertFalse( $res['reactivated'] ); + $this->assertFalse( is_plugin_active( self::PLUGIN ) ); + $this->assertSame( array(), $GLOBALS['_activate_plugin_calls'], 'a plugin that was inactive is never activated' ); + } + + public function test_an_inactive_plugin_whose_upgrade_fails_stays_inactive(): void { + $GLOBALS['_upgrade_result'] = null; + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertFalse( $res['success'] ); + $this->assertFalse( $res['reactivated'] ); + $this->assertSame( array(), $GLOBALS['_activate_plugin_calls'] ); + } + + public function test_a_network_active_plugin_is_reactivated_network_wide(): void { + $GLOBALS['_is_multisite'] = true; + $GLOBALS['_network_active_plugins'][ self::PLUGIN ] = true; + $this->deactivate_during_upgrade(); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertTrue( $res['success'] ); + $this->assertTrue( $res['reactivated'] ); + $this->assertCount( 1, $GLOBALS['_activate_plugin_calls'] ); + $this->assertTrue( $GLOBALS['_activate_plugin_calls'][0]['network_wide'] ); + $this->assertTrue( $GLOBALS['_activate_plugin_calls'][0]['silent'] ); + $this->assertTrue( is_plugin_active_for_network( self::PLUGIN ) ); + } + + public function test_a_failed_reactivation_is_reported_with_its_message(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $GLOBALS['_activate_plugin_result'] = new WP_Error( 'plugin_invalid', 'Plugin file does not exist.' ); + $this->deactivate_during_upgrade(); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertFalse( $res['reactivated'] ); + $this->assertSame( 'Plugin file does not exist.', $res['reactivation_error'] ); + $this->assertCount( 1, $GLOBALS['_activate_plugin_calls'] ); + } + + public function test_a_successful_reactivation_carries_no_reactivation_error(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $this->deactivate_during_upgrade(); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertArrayNotHasKey( 'reactivation_error', $res ); + } + + // ----- the batch (and update_plugin_safely, which goes through it) --------- + + public function test_a_batch_entry_for_an_active_plugin_is_reactivated(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $this->deactivate_during_upgrade(); + + $entry = $this->batch_entry( ( new Aura_Worker_Updater() )->batch_update_plugins( array( self::PLUGIN ), 5, false ) ); + + $this->assertTrue( is_plugin_active( self::PLUGIN ) ); + $this->assertTrue( $entry['reactivated'] ); + $this->assertCount( 1, $GLOBALS['_activate_plugin_calls'] ); + $this->assertTrue( $GLOBALS['_activate_plugin_calls'][0]['silent'] ); + } + + public function test_a_failed_batch_entry_for_an_active_plugin_is_reactivated(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $GLOBALS['_upgrade_result'] = new WP_Error( 'copy_failed', 'Could not copy file.' ); + $this->deactivate_during_upgrade(); + + $entry = $this->batch_entry( ( new Aura_Worker_Updater() )->batch_update_plugins( array( self::PLUGIN ), 5, false ) ); + + $this->assertSame( 'failed', $entry['status'] ); + $this->assertSame( 'Could not copy file.', $entry['detail'] ); + $this->assertSame( 'copy_failed', $entry['code'] ); + $this->assertTrue( $entry['reactivated'] ); + $this->assertTrue( is_plugin_active( self::PLUGIN ) ); + } + + public function test_a_batch_entry_for_an_inactive_plugin_stays_inactive(): void { + $entry = $this->batch_entry( ( new Aura_Worker_Updater() )->batch_update_plugins( array( self::PLUGIN ), 5, false ) ); + + $this->assertFalse( $entry['reactivated'] ); + $this->assertFalse( is_plugin_active( self::PLUGIN ) ); + $this->assertSame( array(), $GLOBALS['_activate_plugin_calls'] ); + } + + public function test_a_network_active_batch_entry_is_reactivated_network_wide(): void { + $GLOBALS['_is_multisite'] = true; + $GLOBALS['_network_active_plugins'][ self::PLUGIN ] = true; + $this->deactivate_during_upgrade(); + + $entry = $this->batch_entry( ( new Aura_Worker_Updater() )->batch_update_plugins( array( self::PLUGIN ), 5, false ) ); + + $this->assertTrue( $entry['reactivated'] ); + $this->assertTrue( $GLOBALS['_activate_plugin_calls'][0]['network_wide'] ); + } + + public function test_update_plugin_safely_leaves_an_active_plugin_active(): void { + $GLOBALS['_installed_plugins'] = array( self::PLUGIN => array( 'Name' => 'Akismet', 'Version' => '1.0' ) ); + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $this->deactivate_during_upgrade(); + $tool = new Aura_Tool_Update_Plugin_Safely(); + + try { + $tool->execute( array( 'plugin_slug' => 'akismet', 'create_backup' => false ) ); + } finally { + unset( $GLOBALS['_installed_plugins'] ); + } + + $this->assertTrue( is_plugin_active( self::PLUGIN ) ); + $this->assertCount( 1, $GLOBALS['_activate_plugin_calls'] ); + } +} diff --git a/tests/unit/UpdatePluginResultMappingTest.php b/tests/unit/UpdatePluginResultMappingTest.php new file mode 100644 index 00000000..cfba0104 --- /dev/null +++ b/tests/unit/UpdatePluginResultMappingTest.php @@ -0,0 +1,146 @@ +response[$plugin]` has no entry — nothing + * is offered and nothing was touched; + * - `null` when `run()` failed: `$result` has no default and run()'s own + * return (the WP_Error) is discarded. It used to be reported as "No update + * available for this plugin." — the update WAS offered; the download, + * unpack or install failed. The reason survives only in the skin. + * - a WP_Error, kept as it is. + * + * A missing offer is refreshed once (`wp_update_plugins()`) before upgrade(). + * + * @package Aura_Worker\Tests + */ + +use PHPUnit\Framework\TestCase; + +final class UpdatePluginResultMappingTest extends TestCase { + + private const PLUGIN = 'akismet/akismet.php'; + + protected function setUp(): void { + sa_reset_state(); + $GLOBALS['_fs_method'] = 'ftpext'; + } + + protected function tearDown(): void { + unset( $GLOBALS['_fs_method'] ); + } + + private function offer( string $version = '2.0' ): void { + $GLOBALS['_site_transients']['update_plugins'] = (object) array( + 'response' => array( self::PLUGIN => (object) array( 'new_version' => $version ) ), + ); + } + + public function test_null_is_an_update_failure_carrying_the_skins_reason(): void { + $this->offer(); + $GLOBALS['_upgrade_result'] = null; + $GLOBALS['_upgrade_messages'] = array( + 'Downloading update from https://example.com/akismet.zip…', + 'Download failed. Forbidden', + 'Plugin update failed.', + ); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertFalse( $res['success'] ); + $this->assertSame( 'aura_update_failed', $res['code'] ); + $this->assertStringStartsWith( 'Update failed', $res['error'] ); + $this->assertStringContainsString( 'Download failed. Forbidden', $res['error'] ); + $this->assertStringNotContainsString( 'No update available', $res['error'] ); + } + + public function test_null_with_no_skin_messages_is_still_a_failure_and_not_no_update(): void { + $this->offer(); + $GLOBALS['_upgrade_result'] = null; + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertFalse( $res['success'] ); + $this->assertSame( 'aura_update_failed', $res['code'] ); + $this->assertStringStartsWith( 'Update failed', $res['error'] ); + $this->assertStringNotContainsString( 'No update available', $res['error'] ); + } + + public function test_false_is_no_update_offered(): void { + $this->offer(); + $GLOBALS['_upgrade_result'] = false; + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertFalse( $res['success'] ); + $this->assertSame( 'aura_no_update_offered', $res['code'] ); + } + + public function test_a_wp_error_keeps_its_message_and_code(): void { + $this->offer(); + $GLOBALS['_upgrade_result'] = new WP_Error( 'fs_unavailable', 'Could not access filesystem.' ); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertFalse( $res['success'] ); + $this->assertSame( 'fs_unavailable', $res['code'] ); + $this->assertSame( 'Could not access filesystem.', $res['error'] ); + } + + public function test_success_reports_the_offered_version(): void { + $this->offer( '5.4.1' ); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertTrue( $res['success'] ); + $this->assertSame( '5.4.1', $res['offered_version'] ); + $this->assertNotContains( 'wp_update_plugins', $GLOBALS['_updater_calls'], 'an existing offer is not refreshed' ); + } + + public function test_a_missing_offer_is_refreshed_once_before_the_upgrade(): void { + $GLOBALS['_wp_update_plugins_effect'] = function () { + $this->offer( '3.1' ); + }; + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertSame( array( 'wp_update_plugins', 'Plugin_Upgrader::upgrade:' . self::PLUGIN ), $GLOBALS['_updater_calls'] ); + $this->assertTrue( $res['success'] ); + $this->assertSame( '3.1', $res['offered_version'], 'the offer is re-read after the refresh' ); + } + + public function test_a_refresh_that_still_offers_nothing_upgrades_once_and_says_not_offered(): void { + $GLOBALS['_upgrade_result'] = false; // what core answers with no entry + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertSame( array( 'wp_update_plugins', 'Plugin_Upgrader::upgrade:' . self::PLUGIN ), $GLOBALS['_updater_calls'] ); + $this->assertSame( 'aura_no_update_offered', $res['code'] ); + $this->assertArrayNotHasKey( 'offered_version', $res ); + } + + public function test_the_batch_maps_null_and_false_the_same_way(): void { + $this->offer(); + $GLOBALS['_upgrade_result'] = null; + $GLOBALS['_upgrade_messages'] = array( 'Unpacking the update…', 'The package could not be installed. PCLZIP_ERR_BAD_FORMAT' ); + + $null = ( new Aura_Worker_Updater() )->batch_update_plugins( array( self::PLUGIN ), 5, false )['results'][0]; + + $this->assertSame( 'failed', $null['status'] ); + $this->assertSame( 'aura_update_failed', $null['code'] ); + $this->assertStringContainsString( 'PCLZIP_ERR_BAD_FORMAT', $null['detail'] ); + + $GLOBALS['_upgrade_result'] = false; + $false = ( new Aura_Worker_Updater() )->batch_update_plugins( array( self::PLUGIN ), 5, false )['results'][0]; + + $this->assertSame( 'failed', $false['status'] ); + $this->assertSame( 'aura_no_update_offered', $false['code'] ); + } + + public function test_the_batch_refreshes_a_missing_offer_once_per_entry_before_the_upgrade(): void { + ( new Aura_Worker_Updater() )->batch_update_plugins( array( self::PLUGIN ), 5, false ); + + $this->assertSame( array( 'wp_update_plugins', 'Plugin_Upgrader::upgrade:' . self::PLUGIN ), $GLOBALS['_updater_calls'] ); + } +} diff --git a/tests/unit/UpdatePluginSelfRefusalTest.php b/tests/unit/UpdatePluginSelfRefusalTest.php new file mode 100644 index 00000000..e1d5abdc --- /dev/null +++ b/tests/unit/UpdatePluginSelfRefusalTest.php @@ -0,0 +1,99 @@ +update_plugin( self::SELF ); + + $this->assertFalse( $res['success'] ); + $this->assertSame( 'aura_use_self_update', $res['code'] ); + $this->assertStringContainsString( '/aura/v1/self-update', $res['error'] ); + $this->assertNotContains( 'Plugin_Upgrader::upgrade', $GLOBALS['_mutations'] ); + $this->assertSame( array(), $GLOBALS['_updater_calls'], 'no refresh and no upgrade' ); + $this->assertSame( array(), $GLOBALS['_activate_plugin_calls'] ); + $this->assertTrue( is_plugin_active( self::SELF ) ); + $this->assertNull( sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ), 'nothing left held' ); + } + + public function test_the_batch_refuses_siteagent_and_runs_the_other_entries(): void { + $out = ( new Aura_Worker_Updater() )->batch_update_plugins( array( self::SELF, 'akismet/akismet.php' ), 5, false ); + + $by = array(); + foreach ( $out['results'] as $r ) { + $by[ $r['plugin'] ] = $r; + } + $this->assertSame( 'failed', $by[ self::SELF ]['status'] ); + $this->assertSame( 'aura_use_self_update', $by[ self::SELF ]['code'] ); + $this->assertStringContainsString( '/aura/v1/self-update', $by[ self::SELF ]['detail'] ); + $this->assertFalse( $this->upgraded( self::SELF ), 'SiteAgent never reached the upgrader' ); + $this->assertTrue( $this->upgraded( 'akismet/akismet.php' ), 'the other entry ran' ); + } + + public function test_update_plugin_safely_refuses_siteagent_with_the_same_code(): void { + $GLOBALS['_installed_plugins'] = array( self::SELF => array( 'Name' => 'SiteAgent', 'Version' => '2.23.0' ) ); + + $res = ( new Aura_Tool_Update_Plugin_Safely() )->execute( array( 'plugin_slug' => 'digitizer-site-worker', 'create_backup' => false ) ); + + $this->assertFalse( $res['success'] ); + $this->assertSame( 'aura_use_self_update', $res['code'] ); + $this->assertFalse( $this->upgraded( self::SELF ) ); + } + + public function test_the_multisite_refusal_keeps_its_precedence(): void { + $GLOBALS['_is_multisite'] = true; + + $single = ( new Aura_Worker_Updater() )->update_plugin( self::SELF ); + $batch = ( new Aura_Worker_Updater() )->batch_update_plugins( array( self::SELF ), 5, false ); + + $this->assertSame( 'aura_self_update_multisite_unsupported', $single['code'] ); + $this->assertSame( 'aura_self_update_multisite_unsupported', $batch['results'][0]['code'] ); + $this->assertFalse( $this->upgraded( self::SELF ) ); + } + + public function test_a_held_claim_still_answers_busy(): void { + $holder = Aura_Worker_Magic_Link::take_claim( Aura_Worker_Updater::SELF_UPDATE_LOCK, 10 * MINUTE_IN_SECONDS ); + $this->assertNotSame( '', $holder ); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::SELF ); + + $this->assertFalse( $res['success'] ); + $this->assertTrue( $res['in_progress'] ); + $this->assertFalse( $this->upgraded( self::SELF ) ); + $this->assertStringStartsWith( $holder . '|', (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ) ); + } +} From 83d9400f88c3829e51c3dc9443d62f067f9861d8 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 29 Sep 2026 23:00:52 +0300 Subject: [PATCH 2/4] =?UTF-8?q?release:=20SiteAgent=202.23.1=20=E2=80=94?= =?UTF-8?q?=20a=20generic=20plugin=20update=20keeps=20the=20plugin=20activ?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version header, AURA_WORKER_VERSION, readme.txt Stable tag + Changelog, README.md badge + Changelog; CLAUDE.md documents the generic-update activation guard, aura_use_self_update, aura_no_update_offered, aura_update_failed, reactivated / reactivation_error and offered_version. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 56 +++++++++++++++++-- README.md | 8 ++- .../digitizer-site-worker.php | 4 +- digitizer-site-worker/readme.txt | 7 ++- 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 68041b58..516a0aef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,7 @@ To create an installable ZIP: `cd` to the repo root and run `zip -r digitizer-si | `Aura_Worker_Redact` | `includes/class-aura-worker-redact.php` | Agent read redaction (2.18.0, #419): detectors (`redact`, `redact_text`), audience (`is_audience`), the `rest_pre_echo_response` read seam (`filter_echo`), the `rest_request_before_callbacks` placeholder guard and unredacted-grant check (`before_callbacks`, `grant_shape`), counters, `status_fragment` | | `Aura_Worker_Redact_Decode` | `includes/class-aura-worker-redact-decode.php` | Redaction stage 2 (2.18.2, #113), pure: `decode_layers()` (the raw run, then every pass whose result changed, up to `MAX_DECODE_PASSES` layers of HTML5 character references, `%XX` and JSON escapes — `\\`, `\/`, `\uXXXX`; null past the check pass or the `MAX_DECODE_GROWTH` bound), `decode_run()` (its last layer), `decode_pass()`, `html5_code_point()`, `utf8()` | | `Aura_Worker_Redact_Idna` | `includes/class-aura-worker-redact-idna.php` | Redaction stage 2 view 2 (2.18.3, #116), pure, GENERATED — `map()` applies Unicode 18.0.0's UTS-46 hostname mappings and deletions with one `strtr()`; regenerate with `php bin/generate-idna-map.php` (pinned URL + SHA-256), then run the tests; to move to a new Unicode version, bump `IDNA_VERSION`, `IDNA_URL` and `IDNA_SHA256` in the script, regenerate, then update the three counts AND the MAP content pin in `RedactIdnaTest` | -| `Aura_Worker_Install_Ledger` | `includes/class-aura-worker-install-ledger.php` | The install ledger (2.22.0, Aura spec 2026-09-21 §4): observes `upgrader_package_options` (per-run token in `hook_extra`), `upgrader_pre_download` (`PHP_INT_MAX`: source + context under the token) and `upgrader_install_package_result` (the entry) for plugin/theme runs only; records and never decides. Ring of 200 entries / 90 days in `aura_worker_install_ledger` (+ `_state`), not autoloaded, network options on multisite; `report()` is `audit_agent_code`'s `installs`. `as_siteagent()` wraps the updater's four upgrader calls. `auto_update` = `doing_action( 'wp_maybe_auto_update' )`, never the skin (EMCP Pro installs with `Automatic_Upgrader_Skin`) | +| `Aura_Worker_Install_Ledger` | `includes/class-aura-worker-install-ledger.php` | The install ledger (2.22.0, Aura spec 2026-09-21 §4): observes `upgrader_package_options` (per-run token in `hook_extra`), `upgrader_pre_download` (`PHP_INT_MAX`: source + context under the token) and `upgrader_install_package_result` (the entry) for plugin/theme runs only; records and never decides. Ring of 200 entries / 90 days in `aura_worker_install_ledger` (+ `_state`), not autoloaded, network options on multisite; `report()` is `audit_agent_code`'s `installs`. `as_siteagent()` wraps the updater's three upgrader calls (self-update install, theme upgrade, and `upgrade_plugin_keeping_activation()` — the one generic plugin upgrade since 2.23.1). `auto_update` = `doing_action( 'wp_maybe_auto_update' )`, never the skin (EMCP Pro installs with `Automatic_Upgrader_Skin`) | ### Initialization Flow @@ -128,7 +128,7 @@ All routes are under `/wp-json/aura/v1/`. | Method | Endpoint | Parameters | Description | |--------|----------|------------|-------------| | `POST` | `/update/core` | — | Update WordPress core | -| `POST` | `/update/plugin` | `plugin` (required, string) | Update a specific plugin by file path (e.g. `akismet/akismet.php`) | +| `POST` | `/update/plugin` | `plugin` (required, string) | Update a specific plugin by file path (e.g. `akismet/akismet.php`). Keeps an active plugin active (`reactivated`); `409 aura_use_self_update` for SiteAgent itself, `409 aura_no_update_offered`, `500 aura_update_failed` — see "Generic plugin updates keep the plugin active" | | `POST` | `/update/theme` | `theme` (required, string) | Update a specific theme by slug | | `POST` | `/update/translations` | — | Bulk update all translations | | `POST` | `/update/database` | — | Run `wp_upgrade()` / `dbDelta()` | @@ -176,6 +176,54 @@ and `app_password_probe_unproven: { count, at, owner }` (bounded, saturating) wh could not prove an Application Password gone. Both are always JSON **objects** — the key's presence is the signal, and the shape must not change with its contents. +### Generic plugin updates keep the plugin active (2.23.1) + +`Plugin_Upgrader::upgrade()` hooks core's `deactivate_plugin_before_upgrade` at +`upgrader_pre_install`, which silently deactivates the target whenever +`wp_doing_cron()` is false — a REST request included. wp-admin pairs it with the +skin's re-activation iframe; nothing on the REST path did, so every plugin updated +through `/aura/v1/update/plugin`, `/aura/v2/update/batch` or `update_plugin_safely` +came back **inactive** (on success, and on a failure after pre_install), and an +update of SiteAgent itself removed every `aura/*` route. + +- **One call site.** `Aura_Worker_Updater::upgrade_plugin_keeping_activation()` is + the only generic `Plugin_Upgrader::upgrade()` call for plugins; `update_plugin()` + (inside `guarding_self()` + `heartbeat_during()`) and `update_single_plugin()` + (the batch entry) both go through it, under `Aura_Worker_Install_Ledger::as_siteagent()`. +- **Activation.** It records `is_plugin_active()` and, on multisite, + `is_plugin_active_for_network()` before the upgrade; in a `finally` — success, + failure, a `WP_Error`, `null`/`false`, a throw — a plugin that was active and is + not now gets `activate_plugin( $f, '', $was_network, true )` (silent). A plugin + that was inactive is never activated. Results carry `reactivated` (true only when + re-activation was needed and succeeded) and `reactivation_error` (the activation + `WP_Error`'s message) when it failed; batch entries copy both. +- **SiteAgent is refused.** `SELF_PLUGIN_FILE` answers + `{ success: false, code: aura_use_self_update }` (REST `409`, message pointing at + `POST /aura/v1/self-update`) and never reaches the upgrader or the refresh. The + refusal sits inside the work, so the multisite refusal + (`aura_self_update_multisite_unsupported`), the host refusal and the busy claim + (`in_progress: true`) keep their precedence. A batch refuses only that entry + (`status: failed`, `code`); the other entries run. There is no internal re-route + to `self_update()`. Note that a batch entry with `create_backup` still backs up + before the refusal (`batch_update_one()` backs up before it updates). +- **The result.** `null` is `run()` failing (core's `$result` has no default and + run()'s `WP_Error` is discarded): `aura_update_failed`, `error` = "Update failed:" + plus the skin's reason (`get_errors()` where the skin has it, then the last three + `get_upgrade_messages()`), never "No update available". `false` is + `aura_no_update_offered` (REST `409`, not `500`). A `WP_Error` keeps its code and + message (REST `500`). +- **The offer.** When `update_plugins->response[$f]` is missing, `wp_update_plugins()` + runs once and the transient is re-read before `upgrade()` (the GitHub-hosted + plugins' entries are injected at read time, by code that may not be loaded). + `offered_version` (the entry's `new_version`) is reported when there is an entry. +- **Tests.** `UpdatePluginPreservesActivationTest`, `UpdatePluginSelfRefusalTest`, + `UpdatePluginResultMappingTest`, `RestUpdatePluginStatusTest`. Stubs: + `_upgrade_result` (upgrade()'s return; `null` is a failed run), `_upgrade_effect` + (receives the upgrader and the plugin file; models the pre_install deactivation), + `_upgrade_messages`, `_activate_plugin_calls` / `_activate_plugin_result`, + `_site_transients`, `_wp_update_plugins_effect`, and `_updater_calls` (the order of + `wp_update_plugins` and `Plugin_Upgrader::upgrade:`). + ### Plugin-file mutations on hosts that block `.php` writes (SA#95) On some hosts (WP Engine, proven live) PHP gets `Permission denied` creating, @@ -661,13 +709,13 @@ All options are cleaned up in `uninstall.php`. - **Always use `sanitize_text_field()`** or appropriate sanitizer on user input - **Always use `esc_attr()`, `esc_html()`, `esc_url()`** for output escaping - **Use `hash_equals()`** for all token/secret comparisons (timing-safe) -- **Validate WordPress Upgrader return values thoroughly** — `Plugin_Upgrader::upgrade()` can return `true`, `false`, `null`, `WP_Error`, or an array depending on the outcome. Always check for `is_wp_error()`, `false === $result`, and `null === $result` before assuming success. +- **Validate WordPress Upgrader return values thoroughly** — `Plugin_Upgrader::upgrade()` can return `true`, `false`, `null`, `WP_Error`, or an array depending on the outcome. Always check for `is_wp_error()`, `false === $result`, and `null === $result` before assuming success. For `upgrade()`, `false` means nothing is offered (no `update_plugins` entry; nothing touched) and `null` means `run()` failed — the reason is only in the skin. `upgrade()` also deactivates its target at pre_install; plugin upgrades go through `upgrade_plugin_keeping_activation()` (2.23.1). - **Use `wp_unslash()` before `sanitize_*()`** on `$_SERVER` values ### Error Handling - Return structured arrays from updater methods: `array( 'success' => bool, 'message' => string )` or `array( 'success' => false, 'error' => string )` -- REST handlers wrap results in `WP_REST_Response` with appropriate HTTP status codes (200, 404, 500) +- REST handlers wrap results in `WP_REST_Response` with appropriate HTTP status codes (200, 404, 409, 500) — a designated "nothing to do / wrong path" code is 409, not 500 (e.g. `aura_no_update_offered`, `aura_use_self_update` on `/update/plugin`) - Use `WP_Error` objects in security/permission callbacks — WordPress REST API will convert these to proper error responses ### Dependency Loading diff --git a/README.md b/README.md index 06c503b4..32277414 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ WordPress PHP - Stable + Stable

--- @@ -239,6 +239,12 @@ These plug straight into **Aura's Fleet MCP Gateway**: read tools run on demand, ## Changelog +### 2.23.1 + +- **A generic plugin update keeps the plugin active.** `Plugin_Upgrader::upgrade()` deactivates its target at `upgrader_pre_install` outside cron, and only wp-admin's skin re-activates it, so every plugin updated through `POST /aura/v1/update/plugin`, `POST /aura/v2/update/batch` or `update_plugin_safely` came back inactive. One helper now records the activation state (network activation included) before the upgrade and re-activates silently in a `finally` — on success, failure and a throw — and never activates a plugin that was inactive. Results carry `reactivated` and, when activation fails, `reactivation_error`. +- **SiteAgent is refused on the generic paths.** Its own file answers `aura_use_self_update` (REST `409`, pointing at `POST /aura/v1/self-update`) after the multisite, host and busy-claim refusals; the upgrader is never reached. A batch refuses only that entry. +- **The upgrader's result is reported truthfully.** `null` (a failed `run()`) is `aura_update_failed` with the skin's reason, never "No update available"; `false` is `aura_no_update_offered` (REST `409`, not `500`); a `WP_Error` keeps its code and message. A plugin missing from `update_plugins` gets one `wp_update_plugins()` before the upgrade, and results carry `offered_version` when an update is offered. + ### 2.23.0 - **`publish-document` promoting an autosave is a custom-CSS write** (P7.2, references re-verification 2026-09-28 §4). Elementor 4.3.2's `publish-document` restores the calling user's pending autosave, page settings and CSS included, so a listed promoter (`AUTOSAVE_PROMOTERS`) declares a conservative `custom_css` touch when — and only when — an autosave exists to promote (owner decision). An Elementor without `promote_pending_autosave` promotes nothing; an unresolved target or a missing `wp_get_post_autosave()` fails conservative. (#141) diff --git a/digitizer-site-worker/digitizer-site-worker.php b/digitizer-site-worker/digitizer-site-worker.php index c7afc0be..1411b3b7 100644 --- a/digitizer-site-worker/digitizer-site-worker.php +++ b/digitizer-site-worker/digitizer-site-worker.php @@ -3,7 +3,7 @@ * Plugin Name: SiteAgent for Aura * Plugin URI: https://my-aura.app/siteagent * Description: Remote site management agent for Aura dashboard. Enables secure updates, health monitoring, and maintenance operations via REST API. - * Version: 2.23.0 + * Version: 2.23.1 * Requires at least: 6.2 * Requires PHP: 7.4 * Author: Digitizer @@ -18,7 +18,7 @@ exit; } -define( 'AURA_WORKER_VERSION', '2.23.0' ); +define( 'AURA_WORKER_VERSION', '2.23.1' ); define( 'AURA_WORKER_FILE', __FILE__ ); define( 'AURA_WORKER_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/digitizer-site-worker/readme.txt b/digitizer-site-worker/readme.txt index f0d66c6d..e260d90f 100644 --- a/digitizer-site-worker/readme.txt +++ b/digitizer-site-worker/readme.txt @@ -4,7 +4,7 @@ Tags: ai, automation, maintenance, updates, wordpress management Requires at least: 6.2 Tested up to: 7.1 Requires PHP: 7.4 -Stable tag: 2.23.0 +Stable tag: 2.23.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -253,6 +253,11 @@ Yes. SiteAgent is open source under the GPLv2 or later license. The source code == Changelog == += 2.23.1 = +* Fix: updating a plugin through Aura no longer leaves it deactivated. WordPress switches a plugin off while it updates it, and only its own admin screen switches it back on; SiteAgent now turns an active plugin back on after the update, whether the update worked or failed. An inactive plugin stays inactive. +* SiteAgent no longer updates itself through the generic plugin update, which could switch it off and cut Aura off from the site. It updates itself only through its own protected self-update. +* A failed update now reports WordPress's actual reason instead of "No update available", and an update that is not on offer is reported as such rather than as an error. The update list is refreshed once before updating when the plugin is missing from it. + = 2.23.0 = * Operator rules about custom CSS now also cover Elementor's publish-document when publishing would take a pending autosave live, since that autosave can carry CSS no rule has seen. A publish with no autosave is unaffected. If an autosave appears while a call is being approved or run, the call is stopped and can be retried, or it publishes without that autosave, which stays waiting for the next publish. No new settings. From 576481e2576eac12275396db3f3e137fb9dd6cee Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 29 Sep 2026 23:11:48 +0300 Subject: [PATCH 3/4] fix: force the offer refresh; refuse self before the batch backup (Codex r1 on #143) - A missing update_plugins entry now forces the list stale before the refresh: last_checked = 0 is written back with set_site_transient (never deleted, so the other plugins' offers survive), because wp_update_plugins() returns early inside its timeout. The write is core's key and is acknowledged as a foreign dynamic write in UninstallCoverageTest. - The batch refuses SiteAgent's own entry (aura_use_self_update) inside its guarded work, before the recovery helper is built and before batch_update_one()'s backup, so no backup is taken and an unwritable backup directory cannot turn it into a backup failure. The multisite, host and busy refusals still come first; the shared helper keeps its check. - Three batch-only SA#80 lease tests for a SiteAgent entry are retired (unreachable now; the self-update path pins the same guarantees); the three in-phase heartbeat tests move to self_update()'s install(). Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 17 +- .../includes/class-aura-worker-updater.php | 48 +++- tests/bootstrap.php | 11 + tests/unit/SelfUpdateRecoveryTest.php | 261 ++++++------------ tests/unit/UninstallCoverageTest.php | 6 + tests/unit/UpdatePluginResultMappingTest.php | 33 +++ tests/unit/UpdatePluginSelfRefusalTest.php | 85 ++++++ 7 files changed, 276 insertions(+), 185 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 516a0aef..9579d0bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,16 +204,25 @@ update of SiteAgent itself removed every `aura/*` route. (`aura_self_update_multisite_unsupported`), the host refusal and the busy claim (`in_progress: true`) keep their precedence. A batch refuses only that entry (`status: failed`, `code`); the other entries run. There is no internal re-route - to `self_update()`. Note that a batch entry with `create_backup` still backs up - before the refusal (`batch_update_one()` backs up before it updates). + to `self_update()`. On the batch path the entry is refused in + `batch_update_plugins()`'s guarded work, **before** the recovery helper is built + and before `batch_update_one()`'s backup (Codex r1 on #143) — no backup of + SiteAgent is taken, and an unwritable backup directory still answers + `aura_use_self_update`. The shared helper refuses it again (defence in depth). + The SA#80 lease/heartbeat for a SiteAgent batch entry is therefore unreachable; + the in-phase heartbeat tests drive `self_update()`'s `install()` instead. - **The result.** `null` is `run()` failing (core's `$result` has no default and run()'s `WP_Error` is discarded): `aura_update_failed`, `error` = "Update failed:" plus the skin's reason (`get_errors()` where the skin has it, then the last three `get_upgrade_messages()`), never "No update available". `false` is `aura_no_update_offered` (REST `409`, not `500`). A `WP_Error` keeps its code and message (REST `500`). -- **The offer.** When `update_plugins->response[$f]` is missing, `wp_update_plugins()` - runs once and the transient is re-read before `upgrade()` (the GitHub-hosted +- **The offer.** When `update_plugins->response[$f]` is missing, the list is forced + stale — `last_checked = 0` written back with `set_site_transient()`, never deleted + (that would drop every other plugin's offer), because `wp_update_plugins()` returns + early inside its timeout (Codex r1 on #143) — then `wp_update_plugins()` + runs once and the transient is re-read before `upgrade()`. That write is core's + key, acknowledged as a foreign dynamic write in `UninstallCoverageTest` (the GitHub-hosted plugins' entries are injected at read time, by code that may not be loaded). `offered_version` (the entry's `new_version`) is reported when there is an entry. - **Tests.** `UpdatePluginPreservesActivationTest`, `UpdatePluginSelfRefusalTest`, diff --git a/digitizer-site-worker/includes/class-aura-worker-updater.php b/digitizer-site-worker/includes/class-aura-worker-updater.php index 98bbe1b8..609eb63e 100644 --- a/digitizer-site-worker/includes/class-aura-worker-updater.php +++ b/digitizer-site-worker/includes/class-aura-worker-updater.php @@ -1765,15 +1765,24 @@ public function update_plugin( $plugin_file ) { */ private function upgrade_plugin_keeping_activation( $plugin_file ) { if ( self::SELF_PLUGIN_FILE === $plugin_file ) { - return array( - 'success' => false, - 'code' => 'aura_use_self_update', - 'error' => __( 'SiteAgent cannot update itself through the generic plugin update: it would be deactivated mid-update and lose its own routes. Use POST /aura/v1/self-update.', 'digitizer-site-worker' ), - ); + return $this->self_target_refusal(); } $offer = $this->offered_update( $plugin_file ); if ( null === $offer ) { + // wp_update_plugins() returns early while last_checked is inside + // its timeout and no installed version changed (Codex r1 on #143), + // so the list is forced stale first. Written back, never deleted: + // deleting would drop every other plugin's offer. + // The key is WordPress core's, not this plugin's: named through a + // variable so UninstallCoverageTest counts it as an acknowledged + // foreign write, never as a key uninstall.php must sweep. + $core_key = 'update_plugins'; + $stale = get_site_transient( $core_key ); + if ( is_object( $stale ) ) { + $stale->last_checked = 0; + set_site_transient( $core_key, $stale ); + } wp_update_plugins(); $offer = $this->offered_update( $plugin_file ); } @@ -1839,6 +1848,20 @@ private function upgrade_plugin_keeping_activation( $plugin_file ) { return $out; } + /** + * The refusal the generic update paths answer for SiteAgent's own file + * (2.23.1): it updates itself only through POST /aura/v1/self-update. + * + * @return array { success: false, code: aura_use_self_update, error } + */ + private function self_target_refusal() { + return array( + 'success' => false, + 'code' => 'aura_use_self_update', + 'error' => __( 'SiteAgent cannot update itself through the generic plugin update: it would be deactivated mid-update and lose its own routes. Use POST /aura/v1/self-update.', 'digitizer-site-worker' ), + ); + } + /** * The `update_plugins` entry offering $plugin_file, or null. * @@ -2059,6 +2082,21 @@ public function batch_update_plugins( $plugins, $chunk_size = 5, $create_backup $verdict = $this->host_php_writes_verdict(); } $entry = $this->guarding_self( $plugin_file, function ( $fence ) use ( $plugin_file, &$rollback, $health, $create_backup ) { + // SiteAgent's own entry is refused here (Codex r1 on #143): + // before the recovery helper is built and before + // batch_update_one() backs it up — an unwritable backup + // directory must not turn this into a backup failure. The + // multisite, host and busy refusals above still come first; + // the shared helper refuses it again (defence in depth). + if ( self::SELF_PLUGIN_FILE === $plugin_file ) { + $refusal = $this->self_target_refusal(); + return array( + 'plugin' => $plugin_file, + 'status' => 'failed', + 'detail' => $refusal['error'], + 'code' => $refusal['code'], + ); + } if ( null === $rollback ) { $rollback = $this->new_rollback(); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ea62a140..b2167ee2 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1648,6 +1648,17 @@ function get_site_transient( $key ) { } } +if ( ! function_exists( 'set_site_transient' ) ) { + // Writes `_site_transients`; logged in `_updater_calls` as + // `set_site_transient:` (Codex r1 on #143: the forced-stale write + // must land before wp_update_plugins()). + function set_site_transient( $key, $value, $expiration = 0 ) { + $GLOBALS['_site_transients'][ $key ] = $value; + $GLOBALS['_updater_calls'][] = 'set_site_transient:' . $key; + return true; + } +} + if ( ! function_exists( 'wp_update_plugins' ) ) { // Logged in `_updater_calls` (beside Plugin_Upgrader::upgrade, so a test // can assert the order); `_wp_update_plugins_effect` models what the diff --git a/tests/unit/SelfUpdateRecoveryTest.php b/tests/unit/SelfUpdateRecoveryTest.php index 7f8319ca..4e538d3a 100644 --- a/tests/unit/SelfUpdateRecoveryTest.php +++ b/tests/unit/SelfUpdateRecoveryTest.php @@ -1362,192 +1362,131 @@ public function test_a_generic_rollback_of_siteagent_waits_on_the_claim_and_rele $this->assertNull( sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ), 'the guarded restore releases what it took' ); } - public function test_a_batch_entry_for_siteagent_that_outlives_its_lease_stops_before_the_health_check(): void { - // SA#80: the generic batch entry ran backup → update → health → rollback - // under a claim it never renewed. Model the update phase running past the - // takeover window and a successor seizing the claim: the entry must stop - // there — no health check, no rollback over the successor's files — and - // must not remove the successor's claim on its way out. - $successor = ''; - $updater = new class( $successor ) extends Aura_Worker_Updater { - private $successor; - public function __construct( &$successor ) { $this->successor = &$successor; } - protected function update_single_plugin( $plugin_file ) { - $held = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); - $fence = substr( $held, 0, strpos( $held, '|' ) ); - update_option( Aura_Worker_Updater::SELF_UPDATE_LOCK, $fence . '|' . ( time() - 11 * MINUTE_IN_SECONDS ) ); - $this->successor = Aura_Worker_Magic_Link::take_claim( Aura_Worker_Updater::SELF_UPDATE_LOCK, 10 * MINUTE_IN_SECONDS ); - return array( 'success' => true ); - } - }; + /* + * Retired in 2.23.1 (Codex r1 on #143): three tests drove SiteAgent's own + * entry through the generic BATCH to exercise the SA#80 lease between its + * phases — outliving the lease before the health check, renewing it + * between phases, and a claim seized during the health probe. The batch + * now refuses that entry (aura_use_self_update) before any backup, lease + * phase or probe, so none of it can happen in production. The same + * guarantees on the path that still runs are pinned by + * test_the_claim_is_renewed_between_phases_so_a_live_update_is_never_seizable, + * test_a_self_update_whose_claim_was_seized_during_install_neither_restores_nor_probes + * and test_a_self_update_whose_claim_is_seized_during_the_verdict_does_not_roll_back. + * The three in-phase heartbeat tests below moved from the batch to the + * self-update's install(), which runs inside the same heartbeat_during(). + */ - $out = $updater->batch_update_plugins( array( Aura_Worker_Updater::SELF_PLUGIN_FILE ), 5, false ); - - $entry = $out['results'][0]; - $this->assertSame( 'failed', $entry['status'] ); - $this->assertStringContainsString( 'Lost the self-update claim', $entry['detail'] ); - $this->assertNotSame( '', $successor, 'the aged claim must be seizable' ); - $this->assertStringStartsWith( $successor . '|', (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ), 'the outlived entry must not remove its successor\'s claim' ); - $this->assertSame( array(), array_filter( $GLOBALS['_wp_http_calls'] ), 'no health probe after the claim was lost' ); - } - - public function test_a_batch_entry_for_siteagent_renews_its_lease_between_phases(): void { - // The lease is renewed after the backup and after the update, so a slow - // phase is never mistaken for a dead holder. Observable as the claim's - // timestamp moving forward across the entry. - $stamps = array(); - $updater = new class( $stamps ) extends Aura_Worker_Updater { - private $stamps; - public function __construct( &$stamps ) { $this->stamps = &$stamps; } - protected function update_single_plugin( $plugin_file ) { - // Age the lease by a minute inside the phase; the renewal after - // this phase must bring it back to "now". - $held = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); - $fence = substr( $held, 0, strpos( $held, '|' ) ); - update_option( Aura_Worker_Updater::SELF_UPDATE_LOCK, $fence . '|' . ( time() - MINUTE_IN_SECONDS ) ); - $this->stamps['aged'] = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); - return array( 'success' => true ); - } - protected function batch_update_one( $plugin_file, $rollback, $health, $create_backup, $fence = '' ) { - $entry = parent::batch_update_one( $plugin_file, $rollback, $health, $create_backup, $fence ); - $this->stamps['renewed'] = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); - return $entry; - } + /** A self-update whose heartbeat is unthrottled, so every sub-phase filter beats. */ + private function heartbeatingSelfUpdate(): array { + $updater = new class() extends Aura_Worker_Updater { + const LEASE_HEARTBEAT_SECONDS = 0; // no throttle in the model }; - - $out = $updater->batch_update_plugins( array( Aura_Worker_Updater::SELF_PLUGIN_FILE ), 5, false ); - - $this->assertSame( 'updated', $out['results'][0]['status'], $out['results'][0]['detail'] ); - $aged = (int) substr( $stamps['aged'], strpos( $stamps['aged'], '|' ) + 1 ); - $renewed = (int) substr( $stamps['renewed'], strpos( $stamps['renewed'], '|' ) + 1 ); - $this->assertGreaterThan( $aged, $renewed, 'the lease was renewed after the update phase' ); - $this->assertNull( sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ), 'released on exit' ); + return $updater->self_update( 'https://github.com/Digitizers/SiteAgent/releases/download/v9.9.9/x.zip' ); } - public function test_the_lease_is_heartbeaten_inside_the_update_phase_through_the_upgraders_filters(): void { + public function test_the_lease_is_heartbeaten_inside_the_install_phase_through_the_upgraders_filters(): void { // Codex #91 round-2 P1: renewing only BETWEEN phases leaves a phase // that runs past the window seizable. The upgrader fires its own - // sub-phase filters (download → source selection → pre-install → - // post-install); a throttled heartbeat hooked on them keeps the lease - // alive while the phase runs. Modelled: the phase ages the lease, then - // fires a sub-phase filter; the lease must be fresh again after it. - $stamps = array(); - $updater = new class( $stamps ) extends Aura_Worker_Updater { - const LEASE_HEARTBEAT_SECONDS = 0; // no throttle in the model - private $stamps; - public function __construct( &$stamps ) { $this->stamps = &$stamps; } - protected function update_single_plugin( $plugin_file ) { - $held = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); - $fence = substr( $held, 0, strpos( $held, '|' ) ); - update_option( Aura_Worker_Updater::SELF_UPDATE_LOCK, $fence . '|' . ( time() - 5 * MINUTE_IN_SECONDS ) ); - $this->stamps['aged'] = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); - apply_filters( 'upgrader_pre_install', true, array() ); // what Plugin_Upgrader fires mid-phase - $this->stamps['beat'] = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); - return array( 'success' => true ); - } + // sub-phase filters; a throttled heartbeat hooked on them keeps the + // lease alive while install() runs. Modelled: the install ages the + // lease, then fires a sub-phase filter; the lease is fresh after it. + $stamps = array(); + $GLOBALS['_install_effect'] = function () use ( &$stamps ) { + $held = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); + $fence = substr( $held, 0, strpos( $held, '|' ) ); + update_option( Aura_Worker_Updater::SELF_UPDATE_LOCK, $fence . '|' . ( time() - 5 * MINUTE_IN_SECONDS ) ); + $stamps['aged'] = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); + apply_filters( 'upgrader_pre_install', true, array() ); // what the upgrader fires mid-phase + $stamps['beat'] = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); + $this->installNewBuild( true ); }; - $out = $updater->batch_update_plugins( array( Aura_Worker_Updater::SELF_PLUGIN_FILE ), 5, false ); + $res = $this->heartbeatingSelfUpdate(); - $this->assertSame( 'updated', $out['results'][0]['status'], $out['results'][0]['detail'] ); + $this->assertTrue( $res['success'], $res['error'] ?? '' ); $aged = (int) substr( $stamps['aged'], strpos( $stamps['aged'], '|' ) + 1 ); $beat = (int) substr( $stamps['beat'], strpos( $stamps['beat'], '|' ) + 1 ); $this->assertGreaterThan( $aged, $beat, 'the sub-phase filter renewed the lease inside the phase' ); $this->assertSame( array(), array_filter( $GLOBALS['_filters']['upgrader_pre_install'] ?? array() ), 'the heartbeat filter is removed after the phase' ); } - public function test_a_seized_claim_after_a_null_install_result_reports_installed_false(): void { - // Codex #94 round-1 P2: `installed` is a claim, and an upgrader that - // answered null never proved it installed anything. - $GLOBALS['_install_result'] = null; - $GLOBALS['_install_effect'] = function () { + public function test_a_heartbeat_that_loses_the_claim_aborts_the_upgraders_pre_stages_and_passes_post_install_through(): void { + // Codex #94 round-3 P1: the three pre-stage filters are WordPress's own + // abort points (a WP_Error there runs nothing), so the heartbeat + // answers one from the first failed check on; post-install, which + // fires after the files are replaced, passes through and the check + // after the phase stops the self-update. + $stamps = array(); + $GLOBALS['_install_effect'] = function () use ( &$stamps ) { $held = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); $fence = substr( $held, 0, strpos( $held, '|' ) ); update_option( Aura_Worker_Updater::SELF_UPDATE_LOCK, $fence . '|' . ( time() - 11 * MINUTE_IN_SECONDS ) ); - $this->assertNotSame( '', Aura_Worker_Magic_Link::take_claim( Aura_Worker_Updater::SELF_UPDATE_LOCK, 10 * MINUTE_IN_SECONDS ) ); - }; - - $res = $this->selfUpdate(); - - $this->assertFalse( $res['success'] ); - $this->assertTrue( $res['in_progress'] ); - $this->assertFalse( $res['installed'], 'null is not proof of an install' ); - $this->assertFalse( $res['rolled_back'] ); - $this->assertFalse( $res['health_checked'] ); - } - - public function test_a_heartbeat_that_loses_the_claim_aborts_the_upgraders_pre_stages_and_passes_post_install_through(): void { - // Codex #94 round-3 P1: a claim seized between two sub-phases used to - // be noticed only after the whole phase. The three pre-stage filters - // are WordPress's own abort points (a WP_Error there runs nothing), so - // the heartbeat answers one from the first failed check on; the - // post-install filter, which fires after the files are replaced, - // passes through and the boundary check stops the entry. - $stamps = array(); - $updater = new class( $stamps ) extends Aura_Worker_Updater { - const LEASE_HEARTBEAT_SECONDS = 0; - private $stamps; - public function __construct( &$stamps ) { $this->stamps = &$stamps; } - protected function update_single_plugin( $plugin_file ) { - $held = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); - $fence = substr( $held, 0, strpos( $held, '|' ) ); - update_option( Aura_Worker_Updater::SELF_UPDATE_LOCK, $fence . '|' . ( time() - 11 * MINUTE_IN_SECONDS ) ); - $this->stamps['successor'] = Aura_Worker_Magic_Link::take_claim( Aura_Worker_Updater::SELF_UPDATE_LOCK, 10 * MINUTE_IN_SECONDS ); - $this->stamps['pre'] = apply_filters( 'upgrader_pre_install', true, array() ); - $this->stamps['post'] = apply_filters( 'upgrader_post_install', true, array(), array() ); - $this->stamps['pre_again'] = apply_filters( 'upgrader_pre_download', false, 'pkg', null, array() ); - return array( 'success' => true ); - } + $stamps['successor'] = Aura_Worker_Magic_Link::take_claim( Aura_Worker_Updater::SELF_UPDATE_LOCK, 10 * MINUTE_IN_SECONDS ); + $stamps['pre'] = apply_filters( 'upgrader_pre_install', true, array() ); + $stamps['post'] = apply_filters( 'upgrader_post_install', true, array(), array() ); + $stamps['pre_again'] = apply_filters( 'upgrader_pre_download', false, 'pkg', null, array() ); }; - $out = $updater->batch_update_plugins( array( Aura_Worker_Updater::SELF_PLUGIN_FILE ), 5, false ); + $res = $this->heartbeatingSelfUpdate(); $this->assertNotSame( '', $stamps['successor'], 'the aged claim must be seizable' ); $this->assertInstanceOf( WP_Error::class, $stamps['pre'], 'a lost claim aborts the pre-install stage' ); $this->assertSame( 'aura_self_update_claim_lost', $stamps['pre']->get_error_code() ); $this->assertTrue( $stamps['post'], 'post-install passes its value through — the files are already replaced' ); $this->assertInstanceOf( WP_Error::class, $stamps['pre_again'], 'once lost, every later pre-stage aborts without re-checking' ); - $this->assertSame( 'failed', $out['results'][0]['status'] ); - $this->assertStringContainsString( 'Lost the self-update claim', $out['results'][0]['detail'] ); + $this->assertFalse( $res['success'] ); + $this->assertTrue( $res['in_progress'] ); + $this->assertFalse( $res['rolled_back'] ); $this->assertStringStartsWith( $stamps['successor'] . '|', (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ), 'the successor\'s claim is untouched' ); } public function test_a_claim_seized_between_pre_install_and_the_clear_aborts_before_the_old_directory_is_deleted(): void { - // Codex #94 round-8 P1: the beats sat only on the pre-stages and - // post-install, so a claim seized after upgrader_pre_install was - // noticed only after the install had already cleared and rewritten - // the directory beside its successor. upgrader_clear_destination is - // WordPress's own filter immediately before the delete (the delete - // itself runs inside it, at priority 10); the beat at priority 1 renews - // there and a lost claim answers a WP_Error that stops the phase with - // the old files untouched. - $stamps = array(); - $updater = new class( $stamps ) extends Aura_Worker_Updater { - const LEASE_HEARTBEAT_SECONDS = 0; - private $stamps; - public function __construct( &$stamps ) { $this->stamps = &$stamps; } - protected function update_single_plugin( $plugin_file ) { - $this->stamps['pre'] = apply_filters( 'upgrader_pre_install', true, array() ); // still ours - $held = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); - $fence = substr( $held, 0, strpos( $held, '|' ) ); - update_option( Aura_Worker_Updater::SELF_UPDATE_LOCK, $fence . '|' . ( time() - 11 * MINUTE_IN_SECONDS ) ); // the clear runs long - $this->stamps['successor'] = Aura_Worker_Magic_Link::take_claim( Aura_Worker_Updater::SELF_UPDATE_LOCK, 10 * MINUTE_IN_SECONDS ); - $this->stamps['clear'] = apply_filters( 'upgrader_clear_destination', true, '/local', '/remote', array() ); - return array( 'success' => true ); - } + // Codex #94 round-8 P1: upgrader_clear_destination is WordPress's own + // filter immediately before the delete (which runs inside it, at + // priority 10); the beat at priority 1 renews there and a lost claim + // answers a WP_Error that stops the phase with the old files untouched. + $stamps = array(); + $GLOBALS['_install_effect'] = function () use ( &$stamps ) { + $stamps['pre'] = apply_filters( 'upgrader_pre_install', true, array() ); // still ours + $held = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); + $fence = substr( $held, 0, strpos( $held, '|' ) ); + update_option( Aura_Worker_Updater::SELF_UPDATE_LOCK, $fence . '|' . ( time() - 11 * MINUTE_IN_SECONDS ) ); // the clear runs long + $stamps['successor'] = Aura_Worker_Magic_Link::take_claim( Aura_Worker_Updater::SELF_UPDATE_LOCK, 10 * MINUTE_IN_SECONDS ); + $stamps['clear'] = apply_filters( 'upgrader_clear_destination', true, '/local', '/remote', array() ); }; - $out = $updater->batch_update_plugins( array( Aura_Worker_Updater::SELF_PLUGIN_FILE ), 5, false ); + $res = $this->heartbeatingSelfUpdate(); $this->assertTrue( $stamps['pre'], 'pre-install passed while the claim was ours' ); $this->assertNotSame( '', $stamps['successor'], 'the aged claim must be seizable' ); $this->assertInstanceOf( WP_Error::class, $stamps['clear'], 'a lost claim aborts at the clear, before the old directory is deleted' ); $this->assertSame( 'aura_self_update_claim_lost', $stamps['clear']->get_error_code() ); - $this->assertSame( 'failed', $out['results'][0]['status'] ); - $this->assertStringContainsString( 'Lost the self-update claim', $out['results'][0]['detail'] ); + $this->assertFalse( $res['success'] ); + $this->assertTrue( $res['in_progress'] ); $this->assertSame( array(), array_filter( $GLOBALS['_filters']['upgrader_clear_destination'] ?? array() ), 'the beat is removed after the phase' ); } + public function test_a_seized_claim_after_a_null_install_result_reports_installed_false(): void { + // Codex #94 round-1 P2: `installed` is a claim, and an upgrader that + // answered null never proved it installed anything. + $GLOBALS['_install_result'] = null; + $GLOBALS['_install_effect'] = function () { + $held = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); + $fence = substr( $held, 0, strpos( $held, '|' ) ); + update_option( Aura_Worker_Updater::SELF_UPDATE_LOCK, $fence . '|' . ( time() - 11 * MINUTE_IN_SECONDS ) ); + $this->assertNotSame( '', Aura_Worker_Magic_Link::take_claim( Aura_Worker_Updater::SELF_UPDATE_LOCK, 10 * MINUTE_IN_SECONDS ) ); + }; + + $res = $this->selfUpdate(); + + $this->assertFalse( $res['success'] ); + $this->assertTrue( $res['in_progress'] ); + $this->assertFalse( $res['installed'], 'null is not proof of an install' ); + $this->assertFalse( $res['rolled_back'] ); + $this->assertFalse( $res['health_checked'] ); + } + public function test_a_generic_single_update_of_siteagent_has_no_upgrade_phase_to_lose_its_claim_in(): void { // Codex #94 round-5 P2 made update_plugin() check its claim after the // upgrade phase, so a claim seized mid-phase was never reported as @@ -1569,36 +1508,6 @@ public function test_a_generic_single_update_of_siteagent_has_no_upgrade_phase_t $this->assertNull( sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ) ); } - public function test_a_batch_entry_whose_claim_is_seized_during_the_health_probe_does_not_roll_back(): void { - // SA#93 (closed): the probe is a loopback request; a claim lost across - // it means the rollback belongs to the successor. - $successor = ''; - $GLOBALS['_http_response'] = array( 'response' => array( 'code' => 500 ), 'body' => '' ); // the verdict would roll back - $GLOBALS['_http_effect'] = function () use ( &$successor ) { - if ( '' !== $successor ) { - return; - } - $held = (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ); - $fence = substr( $held, 0, strpos( $held, '|' ) ); - update_option( Aura_Worker_Updater::SELF_UPDATE_LOCK, $fence . '|' . ( time() - 11 * MINUTE_IN_SECONDS ) ); - $successor = Aura_Worker_Magic_Link::take_claim( Aura_Worker_Updater::SELF_UPDATE_LOCK, 10 * MINUTE_IN_SECONDS ); - }; - $updater = new class extends Aura_Worker_Updater { - protected function update_single_plugin( $plugin_file ) { - file_put_contents( WP_PLUGIN_DIR . '/digitizer-site-worker/digitizer-site-worker.php', " true ); - } - }; - - $out = $updater->batch_update_plugins( array( Aura_Worker_Updater::SELF_PLUGIN_FILE ), 5, true ); - - $this->assertNotSame( '', $successor, 'the claim was seized during the probe' ); - $this->assertSame( 'failed', $out['results'][0]['status'] ); - $this->assertStringContainsString( 'Lost the self-update claim', $out['results'][0]['detail'] ); - $this->assertStringContainsString( 'NEW BUILD', file_get_contents( WP_PLUGIN_DIR . '/digitizer-site-worker/digitizer-site-worker.php' ), 'no rollback over the successor\'s files' ); - $this->assertStringStartsWith( $successor . '|', (string) sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ) ); - } - public function test_a_self_update_whose_claim_is_seized_during_the_verdict_does_not_roll_back(): void { // SA#93 (closed): same seam on the self-update path — the verdict took // a loopback round trip, and a claim lost across it leaves the rollback diff --git a/tests/unit/UninstallCoverageTest.php b/tests/unit/UninstallCoverageTest.php index eb829856..83a21d92 100644 --- a/tests/unit/UninstallCoverageTest.php +++ b/tests/unit/UninstallCoverageTest.php @@ -92,6 +92,11 @@ final class UninstallCoverageTest extends TestCase { * the ONE dynamic write that is not the plugin's own storage: it RESTORES * a site option a snapshot captured, so the key belongs to WordPress or to * another plugin and uninstall must never remove it. + * - includes/class-aura-worker-updater.php — set_site_transient( $core_key ) + * is the second foreign write (2.23.1, Codex r1 on #143): it writes + * WordPress core's own `update_plugins` back with last_checked = 0 so + * wp_update_plugins() actually refreshes. Core's key; uninstall must + * never remove it. */ private const ACKNOWLEDGED_DYNAMIC_WRITES = array( 'includes/boot-beacon.php' => 1, @@ -99,6 +104,7 @@ final class UninstallCoverageTest extends TestCase { 'includes/class-aura-worker-rules.php' => 2, 'includes/class-aura-worker-security.php' => 1, 'includes/class-aura-worker-snapshots.php' => 1, + 'includes/class-aura-worker-updater.php' => 1, ); /** diff --git a/tests/unit/UpdatePluginResultMappingTest.php b/tests/unit/UpdatePluginResultMappingTest.php index cfba0104..14858d6a 100644 --- a/tests/unit/UpdatePluginResultMappingTest.php +++ b/tests/unit/UpdatePluginResultMappingTest.php @@ -110,6 +110,39 @@ public function test_a_missing_offer_is_refreshed_once_before_the_upgrade(): voi $this->assertSame( '3.1', $res['offered_version'], 'the offer is re-read after the refresh' ); } + public function test_a_missing_offer_forces_the_list_stale_before_the_refresh_and_keeps_the_other_entries(): void { + // Codex r1 on #143: wp_update_plugins() returns early while + // last_checked is inside its timeout and no installed version changed, + // so a plain call refreshes nothing. The list is forced stale first — + // last_checked = 0, written back, never deleted (the other plugins' + // entries must survive). + $recent = time() - 60; + $GLOBALS['_site_transients']['update_plugins'] = (object) array( + 'last_checked' => $recent, + 'response' => array( 'other/other.php' => (object) array( 'new_version' => '9.0' ) ), + ); + $seen = null; + $GLOBALS['_wp_update_plugins_effect'] = function () use ( &$seen ) { + $seen = get_site_transient( 'update_plugins' ); + }; + + ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertIsObject( $seen, 'the transient was not deleted' ); + $this->assertSame( 0, $seen->last_checked, 'wp_update_plugins() saw the list forced stale' ); + $this->assertArrayHasKey( 'other/other.php', $seen->response, 'the other plugins\' entries survive' ); + $this->assertSame( + array( 'set_site_transient:update_plugins', 'wp_update_plugins', 'Plugin_Upgrader::upgrade:' . self::PLUGIN ), + $GLOBALS['_updater_calls'] + ); + } + + public function test_no_transient_at_all_is_refreshed_without_writing_one(): void { + ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertSame( array( 'wp_update_plugins', 'Plugin_Upgrader::upgrade:' . self::PLUGIN ), $GLOBALS['_updater_calls'] ); + } + public function test_a_refresh_that_still_offers_nothing_upgrades_once_and_says_not_offered(): void { $GLOBALS['_upgrade_result'] = false; // what core answers with no entry diff --git a/tests/unit/UpdatePluginSelfRefusalTest.php b/tests/unit/UpdatePluginSelfRefusalTest.php index e1d5abdc..eca3ae6a 100644 --- a/tests/unit/UpdatePluginSelfRefusalTest.php +++ b/tests/unit/UpdatePluginSelfRefusalTest.php @@ -64,6 +64,91 @@ public function test_the_batch_refuses_siteagent_and_runs_the_other_entries(): v $this->assertTrue( $this->upgraded( 'akismet/akismet.php' ), 'the other entry ran' ); } + /** + * An updater whose recovery helper records every backup_plugin() call and + * answers $backup for it, touching no disk. + */ + private function recordingUpdater( array &$backed_up, array $backup ): Aura_Worker_Updater { + return new class( $backed_up, $backup ) extends Aura_Worker_Updater { + private $backed_up; + private $backup; + public function __construct( &$backed_up, $backup ) { + $this->backed_up = &$backed_up; + $this->backup = $backup; + } + protected function new_rollback() { + $rec = &$this->backed_up; + $ans = $this->backup; + return new class( $rec, $ans ) extends Aura_Worker_Rollback { + private $rec; + private $ans; + public function __construct( &$rec, $ans ) { + $this->rec = &$rec; + $this->ans = $ans; + } + public function backup_plugin( $plugin_slug ) { + $this->rec[] = $plugin_slug; + return $this->ans; + } + public function cleanup_old_backups( $keep_per_plugin = 3 ) {} + }; + } + }; + } + + public function test_a_batch_with_backups_refuses_siteagent_before_backing_it_up(): void { + // Codex r1 on #143: batch_update_one() backs up before it updates, so + // the refusal inside the shared helper came after a backup of + // SiteAgent. The self-target is refused before any backup. + $backed_up = array(); + $updater = $this->recordingUpdater( $backed_up, array( 'success' => true, 'backup_path' => '/tmp/fake.zip' ) ); + + $out = $updater->batch_update_plugins( array( self::SELF, 'akismet/akismet.php' ), 5, true ); + + $by = array(); + foreach ( $out['results'] as $r ) { + $by[ $r['plugin'] ] = $r; + } + $this->assertSame( 'failed', $by[ self::SELF ]['status'] ); + $this->assertSame( 'aura_use_self_update', $by[ self::SELF ]['code'] ); + $this->assertNotContains( 'digitizer-site-worker', $backed_up, 'no backup is made for SiteAgent' ); + $this->assertFalse( $this->upgraded( self::SELF ) ); + $this->assertSame( array( 'akismet' ), $backed_up, 'the other plugin is backed up' ); + $this->assertTrue( $this->upgraded( 'akismet/akismet.php' ), 'and updated' ); + $this->assertNull( sa_read_option_uncached( Aura_Worker_Updater::SELF_UPDATE_LOCK ), 'nothing left held' ); + } + + public function test_siteagent_with_an_unwritable_backup_dir_is_still_the_self_refusal(): void { + $backed_up = array(); + $updater = $this->recordingUpdater( $backed_up, array( 'success' => false, 'error' => 'Backup directory is not writable' ) ); + + $out = $updater->batch_update_plugins( array( self::SELF ), 5, true ); + + $this->assertSame( 'aura_use_self_update', $out['results'][0]['code'] ); + $this->assertStringNotContainsString( 'Backup failed', $out['results'][0]['detail'] ); + $this->assertSame( array(), $backed_up ); + } + + public function test_update_plugin_safely_refuses_siteagent_before_its_default_backup(): void { + $GLOBALS['_installed_plugins'] = array( self::SELF => array( 'Name' => 'SiteAgent', 'Version' => '2.23.0' ) ); + $backed_up = array(); + $updater = $this->recordingUpdater( $backed_up, array( 'success' => false, 'error' => 'Backup directory is not writable' ) ); + $tool = new class( $updater ) extends Aura_Tool_Update_Plugin_Safely { + private $u; + public function __construct( $u ) { + $this->u = $u; + } + protected function new_updater() { + return $this->u; + } + }; + + $res = $tool->execute( array( 'plugin_slug' => 'digitizer-site-worker' ) ); // create_backup defaults to true + + $this->assertSame( 'aura_use_self_update', $res['code'] ); + $this->assertSame( array(), $backed_up ); + } + public function test_update_plugin_safely_refuses_siteagent_with_the_same_code(): void { $GLOBALS['_installed_plugins'] = array( self::SELF => array( 'Name' => 'SiteAgent', 'Version' => '2.23.0' ) ); From 637ba40d01eb26ec109954b5d8a15c8790920fa4 Mon Sep 17 00:00:00 2001 From: Ben Kalsky Date: Tue, 29 Sep 2026 23:17:52 +0300 Subject: [PATCH 4/4] fix: a failed re-activation fails the update (Codex r2 on #143) When the upgrade succeeded but activate_plugin() returned a WP_Error, the result stayed success:true: REST answered 200, the batch entry was 'updated' and update_plugin_safely dropped the error while the plugin was left disabled. It is now success:false with code aura_reactivation_failed (REST 500), updated:true and reactivation_error; the batch entry is 'failed' with that code. A failed upgrade keeps its own code as primary and still carries reactivation_error. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 11 +++- .../includes/class-aura-worker-updater.php | 13 ++++ tests/unit/RestUpdatePluginStatusTest.php | 16 +++++ .../UpdatePluginPreservesActivationTest.php | 63 +++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9579d0bc..eb6226d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,7 +128,7 @@ All routes are under `/wp-json/aura/v1/`. | Method | Endpoint | Parameters | Description | |--------|----------|------------|-------------| | `POST` | `/update/core` | — | Update WordPress core | -| `POST` | `/update/plugin` | `plugin` (required, string) | Update a specific plugin by file path (e.g. `akismet/akismet.php`). Keeps an active plugin active (`reactivated`); `409 aura_use_self_update` for SiteAgent itself, `409 aura_no_update_offered`, `500 aura_update_failed` — see "Generic plugin updates keep the plugin active" | +| `POST` | `/update/plugin` | `plugin` (required, string) | Update a specific plugin by file path (e.g. `akismet/akismet.php`). Keeps an active plugin active (`reactivated`); `409 aura_use_self_update` for SiteAgent itself, `409 aura_no_update_offered`, `500 aura_update_failed`, `500 aura_reactivation_failed` (updated, but left inactive) — see "Generic plugin updates keep the plugin active" | | `POST` | `/update/theme` | `theme` (required, string) | Update a specific theme by slug | | `POST` | `/update/translations` | — | Bulk update all translations | | `POST` | `/update/database` | — | Run `wp_upgrade()` / `dbDelta()` | @@ -196,7 +196,14 @@ update of SiteAgent itself removed every `aura/*` route. not now gets `activate_plugin( $f, '', $was_network, true )` (silent). A plugin that was inactive is never activated. Results carry `reactivated` (true only when re-activation was needed and succeeded) and `reactivation_error` (the activation - `WP_Error`'s message) when it failed; batch entries copy both. + `WP_Error`'s message) when it failed; batch entries copy both. **A failed + re-activation fails the update** (Codex r2 on #143): after a successful upgrade it + answers `success: false`, `code: aura_reactivation_failed` (REST `500` — the site + needs attention), `updated: true` (the files did change) and an `error` naming the + activation message and "Activate it from wp-admin"; the batch entry is `failed` + with that code and `update_plugin_safely` reports the failure. When the upgrade + itself failed too, the upgrade's code and error stay primary and + `reactivation_error` is attached beside them. - **SiteAgent is refused.** `SELF_PLUGIN_FILE` answers `{ success: false, code: aura_use_self_update }` (REST `409`, message pointing at `POST /aura/v1/self-update`) and never reaches the upgrader or the refresh. The diff --git a/digitizer-site-worker/includes/class-aura-worker-updater.php b/digitizer-site-worker/includes/class-aura-worker-updater.php index 609eb63e..9992d7c4 100644 --- a/digitizer-site-worker/includes/class-aura-worker-updater.php +++ b/digitizer-site-worker/includes/class-aura-worker-updater.php @@ -1834,6 +1834,19 @@ private function upgrade_plugin_keeping_activation( $plugin_file ) { ? sprintf( __( 'Update failed: %s', 'digitizer-site-worker' ), $reason ) : __( 'Update failed: the upgrader gave no reason.', 'digitizer-site-worker' ), ); + } elseif ( null !== $react_error ) { + // The files changed but the plugin is left disabled (Codex r2 on + // #143): not a success. `updated` says the upgrade itself landed. + $out = array( + 'success' => false, + 'code' => 'aura_reactivation_failed', + 'updated' => true, + 'error' => sprintf( + /* translators: %s: activate_plugin()'s error message */ + __( 'The plugin was updated but could not be re-activated: %s. Activate it from wp-admin.', 'digitizer-site-worker' ), + rtrim( $react_error, '.' ) + ), + ); } else { $out = array( 'success' => true ); } diff --git a/tests/unit/RestUpdatePluginStatusTest.php b/tests/unit/RestUpdatePluginStatusTest.php index fd64295f..490a21ca 100644 --- a/tests/unit/RestUpdatePluginStatusTest.php +++ b/tests/unit/RestUpdatePluginStatusTest.php @@ -68,6 +68,22 @@ public function test_a_failed_run_is_500(): void { $this->assertSame( 'aura_update_failed', $res->get_data()['code'] ); } + public function test_a_failed_reactivation_is_500(): void { + // Codex r2 on #143: the files changed but the plugin is left disabled — + // the site needs attention, so not 200 and not a caller error. + $GLOBALS['_active_plugins']['akismet/akismet.php'] = true; + $GLOBALS['_activate_plugin_result'] = new WP_Error( 'plugin_invalid', 'Plugin file does not exist.' ); + $GLOBALS['_upgrade_effect'] = static function () { + unset( $GLOBALS['_active_plugins']['akismet/akismet.php'] ); + }; + + $res = $this->call( 'akismet/akismet.php' ); + + $this->assertSame( 500, $res->get_status() ); + $this->assertSame( 'aura_reactivation_failed', $res->get_data()['code'] ); + $this->assertTrue( $res->get_data()['updated'] ); + } + public function test_success_is_200_and_carries_reactivated(): void { $GLOBALS['_active_plugins']['akismet/akismet.php'] = true; $GLOBALS['_upgrade_effect'] = static function () { diff --git a/tests/unit/UpdatePluginPreservesActivationTest.php b/tests/unit/UpdatePluginPreservesActivationTest.php index 95bc0767..67888bd8 100644 --- a/tests/unit/UpdatePluginPreservesActivationTest.php +++ b/tests/unit/UpdatePluginPreservesActivationTest.php @@ -161,6 +161,69 @@ public function test_a_failed_reactivation_is_reported_with_its_message(): void $this->assertCount( 1, $GLOBALS['_activate_plugin_calls'] ); } + // ----- a failed re-activation fails the update (Codex r2 on #143) ---------- + + public function test_an_update_whose_reactivation_fails_is_a_failure_that_says_the_files_changed(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $GLOBALS['_activate_plugin_result'] = new WP_Error( 'plugin_invalid', 'Plugin file does not exist.' ); + $this->deactivate_during_upgrade(); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertFalse( $res['success'], 'a plugin left disabled is not a successful update' ); + $this->assertSame( 'aura_reactivation_failed', $res['code'] ); + $this->assertTrue( $res['updated'], 'the files did change' ); + $this->assertStringContainsString( 'could not be re-activated', $res['error'] ); + $this->assertStringContainsString( 'Plugin file does not exist.', $res['error'] ); + $this->assertSame( 'Plugin file does not exist.', $res['reactivation_error'] ); + $this->assertArrayNotHasKey( 'message', $res ); + } + + public function test_a_failed_upgrade_with_a_failed_reactivation_keeps_the_upgrades_code(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $GLOBALS['_upgrade_result'] = new WP_Error( 'copy_failed', 'Could not copy file.' ); + $GLOBALS['_activate_plugin_result'] = new WP_Error( 'plugin_invalid', 'Plugin file does not exist.' ); + $this->deactivate_during_upgrade(); + + $res = ( new Aura_Worker_Updater() )->update_plugin( self::PLUGIN ); + + $this->assertFalse( $res['success'] ); + $this->assertSame( 'copy_failed', $res['code'], 'the upgrade failure is the primary code' ); + $this->assertSame( 'Could not copy file.', $res['error'] ); + $this->assertSame( 'Plugin file does not exist.', $res['reactivation_error'] ); + $this->assertArrayNotHasKey( 'updated', $res ); + } + + public function test_a_batch_entry_whose_reactivation_fails_is_failed_with_the_code(): void { + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $GLOBALS['_activate_plugin_result'] = new WP_Error( 'plugin_invalid', 'Plugin file does not exist.' ); + $this->deactivate_during_upgrade(); + + $entry = $this->batch_entry( ( new Aura_Worker_Updater() )->batch_update_plugins( array( self::PLUGIN ), 5, false ) ); + + $this->assertSame( 'failed', $entry['status'] ); + $this->assertSame( 'aura_reactivation_failed', $entry['code'] ); + $this->assertStringContainsString( 'could not be re-activated', $entry['detail'] ); + $this->assertSame( 'Plugin file does not exist.', $entry['reactivation_error'] ); + } + + public function test_update_plugin_safely_reports_a_failed_reactivation_as_a_failure(): void { + $GLOBALS['_installed_plugins'] = array( self::PLUGIN => array( 'Name' => 'Akismet', 'Version' => '1.0' ) ); + $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; + $GLOBALS['_activate_plugin_result'] = new WP_Error( 'plugin_invalid', 'Plugin file does not exist.' ); + $this->deactivate_during_upgrade(); + + try { + $res = ( new Aura_Tool_Update_Plugin_Safely() )->execute( array( 'plugin_slug' => 'akismet', 'create_backup' => false ) ); + } finally { + unset( $GLOBALS['_installed_plugins'] ); + } + + $this->assertFalse( $res['success'] ); + $this->assertSame( 'aura_reactivation_failed', $res['code'] ); + $this->assertStringContainsString( 'Plugin file does not exist.', $res['error'] ); + } + public function test_a_successful_reactivation_carries_no_reactivation_error(): void { $GLOBALS['_active_plugins'][ self::PLUGIN ] = true; $this->deactivate_during_upgrade();