Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion src/Updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand All @@ -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
*
Expand Down
171 changes: 171 additions & 0 deletions tests/unit/UpdaterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php

class UpdaterTest extends \WP_UnitTestCase {
/**
* The licensing client.
*
* @var \SureCart\Licensing\Client
*/
public $client;

/**
* The updater under test.
*
* @var \SureCart\Licensing\Updater
*/
public $updater;

/**
* The plugin basename the client resolved to.
*
* @var string
*/
public $basename;

/**
* Set up the client and updater.
*/
public function setUp() {
parent::setUp();
$this->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'] );
}
}
Loading