Skip to content
Merged
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
74 changes: 70 additions & 4 deletions src/core/class-uninstaller.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
* Uninstall cleanup.
*
* Invoked from `uninstall.php` when WordPress removes the plugin. Deletes
* every option, transient, and post-meta key recorded in `Utils`.
* every option, transient, and post-meta key recorded in `Utils`. On
* multisite each site is cleaned in turn, since `uninstall.php` only runs
* once — in the network's main-site context — and every value is stored
* per-site.
*
* @package BeyondWords\Core
* @since 3.7.0
Expand All @@ -23,6 +26,58 @@
*/
class Uninstaller {

/**
* Run the full uninstall cleanup for every site on the install.
*
* WordPress executes `uninstall.php` a single time, in the context of the
* network's main site. Every BeyondWords value — options, transients and
* post-meta — is stored per-site (`update_option()` and the per-site
* `options`/`postmeta` tables); the plugin never writes network/site
* options. So on multisite we must visit each site in turn, otherwise only
* the main site is cleaned and every subsite keeps its settings, including
* the `beyondwords_api_key` secret. Single-site installs are cleaned in one
* pass.
*
* @since 7.0.0
*
* @return void
*/
public static function run(): void {
if ( ! is_multisite() ) {
self::cleanup_site();
return;
}

// `number => 0` lifts the default 100-site cap so no site is skipped on
// large networks.
$site_ids = get_sites(
[
'fields' => 'ids',
'number' => 0,
]
);

foreach ( $site_ids as $site_id ) {
switch_to_blog( (int) $site_id );
self::cleanup_site();
restore_current_blog();
}
}

/**
* Delete every BeyondWords option, transient and post-meta value for the
* current site.
*
* @since 7.0.0
*
* @return void
*/
private static function cleanup_site(): void {
self::cleanup_plugin_transients();
self::cleanup_plugin_options();
self::cleanup_custom_fields();
}

/**
* Delete every BeyondWords transient.
*
Expand Down Expand Up @@ -59,11 +114,22 @@ public static function cleanup_plugin_options(): int {
$total = 0;

foreach ( $options as $option ) {
$deleted = is_multisite() ? delete_site_option( $option ) : delete_option( $option );

if ( $deleted ) {
// Every option is stored per-site via `update_option()`, so
// `delete_option()` is the correct call on both single-site and
// multisite. The previous `delete_site_option()`-only branch swept
// `wp_sitemeta` rows that were never written and left every real
// option — including the `beyondwords_api_key` secret — in place on
// multisite.
if ( delete_option( $option ) ) {
++$total;
}

// Defensive: a legacy install may have stored a matching network
// (site) option. This is network-global and idempotent, so it is a
// harmless no-op when nothing was stored.
if ( is_multisite() ) {
delete_site_option( $option );
}
}

return $total;
Expand Down
96 changes: 96 additions & 0 deletions tests/phpunit/core/test-uninstaller.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,100 @@ public function cleanup_plugin_transients_removes_beyondwords_transients_only()

delete_transient('unrelated_transient');
}

/**
* @test
*
* run() performs the whole cleanup in a single call on a single-site
* install: options, transients and post-meta are all removed.
*/
public function run_cleans_options_transients_and_meta_on_single_site()
{
if (is_multisite()) {
$this->markTestSkipped('Single-site variant; multisite is covered separately.');
}

update_option('beyondwords_api_key', 'secret');
set_transient('beyondwords_languages', ['en', 'fr'], 60);
$postId = self::factory()->post->create([
'meta_input' => ['beyondwords_project_id' => '123'],
]);

Uninstaller::run();

// The Uninstaller deletes transients/meta with raw SQL, so drop the
// caches before re-reading from the database.
wp_cache_flush();
clean_post_cache($postId);

$this->assertFalse(get_option('beyondwords_api_key'));
$this->assertFalse(get_transient('beyondwords_languages'));
$this->assertFalse(metadata_exists('post', $postId, 'beyondwords_project_id'));

wp_delete_post($postId, true);
}

/**
* @test
*
* run() must visit EVERY site on a multisite network: the API key and other
* per-site values must not survive on any site, not just the main one.
*
* Regression test for the multisite uninstall leak — cleanup previously
* called delete_site_option() (which targets wp_sitemeta) while every
* option is stored per-site via update_option(), so nothing was deleted and
* the beyondwords_api_key secret survived. Skipped on single-site installs
* because it needs a real network (WP_TESTS_MULTISITE=1).
*/
public function run_cleans_every_site_on_multisite()
{
if (!is_multisite()) {
$this->markTestSkipped('Requires a multisite install (WP_TESTS_MULTISITE=1).');
}

$blogId = self::factory()->blog->create();
$siteIds = [get_current_blog_id(), $blogId];

// Seed an option (the API key), a transient and a post-meta value on
// each site so we can prove all three are cleaned everywhere.
$postIds = [];
foreach ($siteIds as $siteId) {
switch_to_blog($siteId);
update_option('beyondwords_api_key', 'secret-' . $siteId);
set_transient('beyondwords_languages', ['en'], 60);
$postIds[$siteId] = self::factory()->post->create([
'meta_input' => ['beyondwords_project_id' => '123'],
]);
restore_current_blog();
}

Uninstaller::run();

foreach ($siteIds as $siteId) {
switch_to_blog($siteId);
wp_cache_flush();
clean_post_cache($postIds[$siteId]);

$this->assertFalse(
get_option('beyondwords_api_key'),
"API key survived on site {$siteId}"
);
$this->assertFalse(
get_transient('beyondwords_languages'),
"Transient survived on site {$siteId}"
);
$this->assertFalse(
metadata_exists('post', $postIds[$siteId], 'beyondwords_project_id'),
"Post meta survived on site {$siteId}"
);
restore_current_blog();
}

// Best-effort teardown of the extra site created for this test.
if (function_exists('wp_delete_site')) {
wp_delete_site(get_site($blogId));
} elseif (function_exists('wpmu_delete_blog')) {
wpmu_delete_blog($blogId, true);
}
}
}
4 changes: 1 addition & 3 deletions uninstall.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ function beyondwords_uninstall() {

require BEYONDWORDS__PLUGIN_DIR . 'vendor/autoload.php';

Uninstaller::cleanup_plugin_transients();
Uninstaller::cleanup_plugin_options();
Uninstaller::cleanup_custom_fields();
Uninstaller::run();
}

// phpcs:disable
Expand Down
Loading