diff --git a/src/Updater.php b/src/Updater.php index 6cb2a08..3944a1c 100644 --- a/src/Updater.php +++ b/src/Updater.php @@ -44,6 +44,7 @@ public function __construct( Client $client ) { */ public function run_plugin_hooks() { add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'check_plugin_update' ) ); + add_filter( 'site_transient_update_plugins', array( $this, 'normalize_plugin_update_transient' ) ); add_filter( 'plugins_api', array( $this, 'plugins_api_filter' ), 10, 3 ); } @@ -73,6 +74,8 @@ public function check_plugin_update( $transient_data ) { } if ( ! empty( $transient_data->response ) && ! empty( $transient_data->response[ $this->client->basename ] ) ) { + // Normalize a possibly stale entry persisted by pre-1.2.0 SDKs, where `icons`/`banners` were stored as stdClass and would fatal on update-core.php. + $transient_data->response[ $this->client->basename ] = $this->normalize_update_assets( $transient_data->response[ $this->client->basename ] ); return $transient_data; } @@ -89,7 +92,7 @@ public function check_plugin_update( $transient_data ) { // If new version available then set to `response`. if ( version_compare( $this->client->project_version, $version_info->new_version, '<' ) ) { - $transient_data->response[ $this->client->basename ] = $version_info; + $transient_data->response[ $this->client->basename ] = $this->normalize_update_assets( $version_info ); } else { // If new version is not available then set to `no_update`. $transient_data->no_update[ $this->client->basename ] = $version_info; @@ -102,6 +105,58 @@ public function check_plugin_update( $transient_data ) { return $transient_data; } + /** + * Normalize a plugin update entry's image assets to arrays. + * + * Pre-1.2.0 SDKs persisted `icons`/`banners` as stdClass into the + * `update_plugins` site transient. WordPress core dereferences these as + * arrays on update-core.php, which fatals on an object. This re-casts them + * defensively and is idempotent (casting an existing array is a no-op). + * + * @param object $entry The plugin update entry. + * @return object The normalized plugin update entry. + */ + private function normalize_update_assets( $entry ) { + if ( ! is_object( $entry ) ) { + return $entry; + } + + if ( isset( $entry->icons ) ) { + $entry->icons = (array) $entry->icons; + } + + if ( isset( $entry->banners ) ) { + $entry->banners = (array) $entry->banners; + } + + return $entry; + } + + /** + * Normalize our plugin's entry on every `update_plugins` transient read. + * + * Fires on `site_transient_update_plugins`, including the read in + * `get_plugin_updates()` on update-core.php. This guards the sub-minute + * window where `wp_update_plugins()` bails without re-setting the transient + * and core reads a stale pre-1.2.0 stdClass entry directly. + * + * @param mixed $transient_data The `update_plugins` site transient. + * @return mixed The transient with our entry normalized. + */ + public function normalize_plugin_update_transient( $transient_data ) { + if ( ! is_object( $transient_data ) || empty( $transient_data->response ) ) { + return $transient_data; + } + + if ( empty( $transient_data->response[ $this->client->basename ] ) ) { + return $transient_data; + } + + $transient_data->response[ $this->client->basename ] = $this->normalize_update_assets( $transient_data->response[ $this->client->basename ] ); + + return $transient_data; + } + /** * Get version info from database * diff --git a/tests/unit/UpdaterTest.php b/tests/unit/UpdaterTest.php new file mode 100644 index 0000000..b08e16a --- /dev/null +++ b/tests/unit/UpdaterTest.php @@ -0,0 +1,171 @@ +client = new \SureCart\Licensing\Client( 'SureCart', __FILE__ ); + $this->updater = $this->client->updater(); + $this->basename = $this->client->basename; + } + + /** + * Build a plugin update entry whose image assets are stdClass. + * + * Mirrors the shape a pre-1.2.0 SDK persisted into the transient. + * + * @return object The plugin update entry. + */ + protected function makeStaleEntry() { + $entry = new \stdClass(); + $entry->plugin = $this->basename; + $entry->icons = (object) array( + '1x' => 'https://example.com/icon-128x128.png', + '2x' => 'https://example.com/icon-256x256.png', + ); + $entry->banners = (object) array( + 'low' => 'https://example.com/banner-772x250.png', + 'high' => 'https://example.com/banner-1544x500.png', + ); + return $entry; + } + + /** + * Wrap an entry in an `update_plugins` transient keyed by our basename. + * + * @param object $entry The plugin update entry. + * @return object The transient. + */ + protected function makeTransient( $entry ) { + $transient = new \stdClass(); + $transient->response = array(); + $transient->response[ $this->basename ] = $entry; + return $transient; + } + + /** + * The read filter recasts a stale stdClass entry's assets to arrays. + * + * This is the assertion that fatals pre-fix. + */ + public function test_read_filter_normalizes_stale_stdclass_entry() { + $transient = $this->makeTransient( $this->makeStaleEntry() ); + + $result = $this->updater->normalize_plugin_update_transient( $transient ); + $entry = $result->response[ $this->basename ]; + + $this->assertTrue( is_array( $entry->icons ) ); + $this->assertTrue( is_array( $entry->banners ) ); + + // Keys and values survive the cast. Mirrors wp-admin/update-core.php:520, where the array deref fataled on a stdClass pre-fix. + $this->assertSame( 'https://example.com/icon-128x128.png', $entry->icons['1x'] ); + $this->assertSame( 'https://example.com/icon-256x256.png', $entry->icons['2x'] ); + $this->assertSame( 'https://example.com/banner-772x250.png', $entry->banners['low'] ); + $this->assertSame( 'https://example.com/banner-1544x500.png', $entry->banners['high'] ); + } + + /** + * Normalizing an entry whose assets are already arrays is a no-op. + */ + public function test_read_filter_is_idempotent_on_array_assets() { + $entry = new \stdClass(); + $entry->plugin = $this->basename; + $entry->icons = array( + '1x' => 'https://example.com/icon-128x128.png', + '2x' => 'https://example.com/icon-256x256.png', + ); + $entry->banners = array( + 'low' => 'https://example.com/banner-772x250.png', + 'high' => 'https://example.com/banner-1544x500.png', + ); + + $transient = $this->makeTransient( $entry ); + + $result = $this->updater->normalize_plugin_update_transient( $transient ); + $out = $result->response[ $this->basename ]; + + $this->assertTrue( is_array( $out->icons ) ); + $this->assertTrue( is_array( $out->banners ) ); + $this->assertSame( $entry->icons, $out->icons ); + $this->assertSame( $entry->banners, $out->banners ); + } + + /** + * The read filter returns a non-object transient unchanged. + */ + public function test_read_filter_no_op_on_non_object() { + $this->assertSame( false, $this->updater->normalize_plugin_update_transient( false ) ); + } + + /** + * The read filter returns a transient with no `response` unchanged. + */ + public function test_read_filter_no_op_when_response_missing() { + $transient = new \stdClass(); + + $result = $this->updater->normalize_plugin_update_transient( $transient ); + + $this->assertSame( $transient, $result ); + $this->assertFalse( isset( $result->response ) ); + } + + /** + * The read filter leaves a transient that lacks our basename untouched. + */ + public function test_read_filter_no_op_when_basename_absent() { + $other = new \stdClass(); + $other->icons = (object) array( '1x' => 'https://example.com/other.png' ); + + $transient = new \stdClass(); + $transient->response = array(); + + $transient->response['other-plugin/other.php'] = $other; + + $result = $this->updater->normalize_plugin_update_transient( $transient ); + $out = $result->response['other-plugin/other.php']; + + // Foreign entries are not touched. + $this->assertTrue( is_object( $out->icons ) ); + } + + /** + * `check_plugin_update` normalizes the existing entry on the re-set/early-return path. + * + * Covers the re-set path, not just the read filter. + */ + public function test_check_plugin_update_normalizes_existing_response_entry() { + $transient = $this->makeTransient( $this->makeStaleEntry() ); + + $result = $this->updater->check_plugin_update( $transient ); + $entry = $result->response[ $this->basename ]; + + $this->assertTrue( is_array( $entry->icons ) ); + $this->assertTrue( is_array( $entry->banners ) ); + // Mirrors wp-admin/update-core.php:520, where the array deref fataled on a stdClass pre-fix. + $this->assertSame( 'https://example.com/icon-128x128.png', $entry->icons['1x'] ); + $this->assertSame( 'https://example.com/banner-1544x500.png', $entry->banners['high'] ); + } +}