From 5ce1df9c3a285a3e1eb02b8da194c6fa8fc2d187 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sun, 1 Nov 2026 10:55:20 +0100 Subject: [PATCH 1/3] Test that the uninstall sweeps keep rows named after the table prefix WordPress names each user's roles on a site and the site's role definitions after the table prefix, as {prefix}capabilities in user meta and {prefix}user_roles in the options. The option_prefixes and user_meta_global_prefixes sweeps match names with no site prefix, so a footprint prefix that heads the table prefix, as a plugin prefix of wp would on a default install, deletes every user's roles and the role definitions. The row runs the engine under a table prefix that a footprint prefix heads and checks that the rows WordPress names after it survive, while the plugin's own rows go, those under a longer prefix that starts with the table prefix included. Assisted-by: Claude Code:claude-opus-5-5 --- .../Core/Uninstall/UninstallerTest.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php index 2cd3aad..26fe4f0 100644 --- a/tests/Integration/Core/Uninstall/UninstallerTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerTest.php @@ -79,6 +79,40 @@ public function test_a_consenting_run_without_a_current_user_deletes_exactly_the self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) ); } + public function test_the_prefix_sweeps_keep_what_wordpress_names_after_a_table_prefix_the_footprint_prefix_heads(): void { + global $wpdb; + $p = $this->prefix; + $table = $wpdb->base_prefix; + update_option( "{$p}_remove_data", 'yes' ); + $this->seed_footprint(); + update_option( "{$p}_tbl_user_roles", 'x' ); + update_option( "{$p}_tbl_acme_a", 'x' ); + add_user_meta( $this->ids['user'], "{$p}_tbl_capabilities", 'x' ); + add_user_meta( $this->ids['user'], "{$p}_tbl_acme_a", 'x' ); + + // WordPress names a site's roles and its users' capabilities after the table prefix, which the engine reads from wpdb. + $wpdb->base_prefix = "{$p}_tbl_"; + try { + Uninstaller::run( + self::PLUGIN_FILE, + array( + 'option_prefixes' => array( "{$p}_cache_", "{$p}_t", "{$p}_tbl_acme_" ), + 'user_meta_global_prefixes' => array( "{$p}_global_", "{$p}_t", "{$p}_tbl_acme_" ), + ), + array( 'consent' => array( 'option' => "{$p}_remove_data" ) ) + ); + } finally { + $wpdb->base_prefix = $table; + } + + self::assertTrue( $this->option_exists( "{$p}_tbl_user_roles" ) ); + self::assertTrue( $this->user_meta_exists( "{$p}_tbl_capabilities" ) ); + self::assertFalse( $this->option_exists( "{$p}_tbl_acme_a" ) ); + self::assertFalse( $this->user_meta_exists( "{$p}_tbl_acme_a" ) ); + self::assertFalse( $this->option_exists( "{$p}_cache_a" ) ); + self::assertFalse( $this->user_meta_exists( "{$p}_global_a" ) ); + } + public function test_the_cleanup_runs_first_while_the_footprint_and_the_consent_row_are_stored(): void { $consent_option = "{$this->prefix}_cache_remove_data"; update_option( $consent_option, 'yes' ); From 433e773fe896e2ad09a0b983fc240a18849c4af1 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sun, 1 Nov 2026 11:01:29 +0100 Subject: [PATCH 2/3] Keep WordPress's roles out of the uninstall's unsited prefix sweeps The option_prefixes and user_meta_global_prefixes sweeps deleted every name under a footprint prefix, so where that prefix heads the table prefix they also deleted {table prefix}user_roles and every user's {table prefix}capabilities, locking every user out of the site. When a footprint prefix heads the table prefix, those two sweeps leave out every name that starts with the table prefix; a prefix that does not head it, such as one longer than the table prefix that starts with it, sweeps as before. Limitation 3 names the rows that stay. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- packages/core/src/Uninstall/Uninstaller.php | 32 +++++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 296bd64..e56156e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships 1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{prefix}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Schema-gated components switch on one request after the migrating request. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so its post types and endpoints first register on the next request. 2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` only reschedules the runner's hook each time it comes due, and the next process continues the migration. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{option}_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge. -3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. On an installation of several networks, the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left. +3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. Where the table prefix starts with a footprint prefix, that prefix's option and global user meta sweeps skip every name that starts with the table prefix, where WordPress keeps roles and capabilities, so the plugin's rows under the table prefix stay. On an installation of several networks, the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left. 4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared. 5. Partial bootstrap package: if `load.php` exists but a file it requires is missing, the `Error` is thrown before the template's catch and the plugin fatals at include; its uninstall shim catches it, except on PHP 7.4, where a failed `require` is fatal. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin. 6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level. diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index 01f3b3d..73cf079 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -217,7 +217,7 @@ protected function uninstall_site( bool $network_footprint ): void { $options = self::get_strings( $this->footprint['options'] ?? null ); foreach ( self::get_strings( $this->footprint['option_prefixes'] ?? null ) as $prefix ) { - $options = \array_merge( $options, $this->get_names_by_prefix( $wpdb->options, 'option_name', $prefix ) ); + $options = \array_merge( $options, $this->get_names_by_prefix_outside_table_prefix( $wpdb->options, 'option_name', $prefix ) ); } foreach ( \array_diff( \array_unique( $options ), array( $this->args['consent']['option'] ) ) as $option ) { $wpdb->flush(); @@ -345,7 +345,7 @@ protected function delete_network_footprint(): void { $this->delete_meta_keys( 'user', self::get_strings( $this->footprint['user_meta_global'] ?? null ) ); foreach ( self::get_strings( $this->footprint['user_meta_global_prefixes'] ?? null ) as $prefix ) { - $this->delete_meta_keys( 'user', $this->get_names_by_prefix( $wpdb->usermeta, 'meta_key', $prefix ) ); + $this->delete_meta_keys( 'user', $this->get_names_by_prefix_outside_table_prefix( $wpdb->usermeta, 'meta_key', $prefix ) ); } } @@ -471,6 +471,34 @@ protected function get_names_by_prefix( string $table, string $column, string $p return \array_values( \array_filter( $names, static fn ( mixed $name ): bool => \is_string( $name ) && \str_starts_with( $name, $prefix ) ) ); } + /** + * Returns the column's distinct values that start with the prefix, leaving out those that start with the table prefix when the prefix heads it. + * + * WordPress names each user's roles on a site and the site's role definitions after the table prefix, so a sweep of names without a site prefix would delete them where the table prefix starts with the plugin's. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @global \wpdb $wpdb The WordPress database connection. + * + * @param string $table The table. + * @param string $column The column. + * @param string $prefix The prefix, matched byte for byte. + * + * @return list + */ + protected function get_names_by_prefix_outside_table_prefix( string $table, string $column, string $prefix ): array { + global $wpdb; + + $names = $this->get_names_by_prefix( $table, $column, $prefix ); + $table_prefix = $wpdb->base_prefix; + if ( ! \str_starts_with( $table_prefix, $prefix ) ) { + return $names; + } + + return \array_values( \array_filter( $names, static fn ( string $name ): bool => ! \str_starts_with( $name, $table_prefix ) ) ); + } + /** * Returns the ids of the rows whose column holds exactly the value, and records a failed read. * From 1ce095c373b1bc257da300a3b7231b6d50aa1dbf Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Sun, 1 Nov 2026 11:44:53 +0100 Subject: [PATCH 3/3] Name the table prefix collision instead of guarding the sweeps The guard kept WordPress's roles and capabilities out of the option and global user meta prefix sweeps when a footprint prefix heads the table prefix, which takes a plugin prefix of wp on the default wp_ table prefix, reserved by WordPress, or one that heads a randomized or hand-picked table prefix. That is a consumer's misconfiguration, not a fault under normal use, so the engine carries no code for it. The guard and its row go, and limitation 3 names the hazard: such an entry also deletes the site's roles and every user's capabilities. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- packages/core/src/Uninstall/Uninstaller.php | 32 ++--------------- .../Core/Uninstall/UninstallerTest.php | 34 ------------------- 3 files changed, 3 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e56156e..a52e4c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,7 +106,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships 1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{prefix}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Schema-gated components switch on one request after the migrating request. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so its post types and endpoints first register on the next request. 2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` only reschedules the runner's hook each time it comes due, and the next process continues the migration. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{option}_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge. -3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. Where the table prefix starts with a footprint prefix, that prefix's option and global user meta sweeps skip every name that starts with the table prefix, where WordPress keeps roles and capabilities, so the plugin's rows under the table prefix stay. On an installation of several networks, the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left. +3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a job that cannot be unscheduled is logged and left. 4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared. 5. Partial bootstrap package: if `load.php` exists but a file it requires is missing, the `Error` is thrown before the template's catch and the plugin fatals at include; its uninstall shim catches it, except on PHP 7.4, where a failed `require` is fatal. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin. 6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level. diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index 73cf079..01f3b3d 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -217,7 +217,7 @@ protected function uninstall_site( bool $network_footprint ): void { $options = self::get_strings( $this->footprint['options'] ?? null ); foreach ( self::get_strings( $this->footprint['option_prefixes'] ?? null ) as $prefix ) { - $options = \array_merge( $options, $this->get_names_by_prefix_outside_table_prefix( $wpdb->options, 'option_name', $prefix ) ); + $options = \array_merge( $options, $this->get_names_by_prefix( $wpdb->options, 'option_name', $prefix ) ); } foreach ( \array_diff( \array_unique( $options ), array( $this->args['consent']['option'] ) ) as $option ) { $wpdb->flush(); @@ -345,7 +345,7 @@ protected function delete_network_footprint(): void { $this->delete_meta_keys( 'user', self::get_strings( $this->footprint['user_meta_global'] ?? null ) ); foreach ( self::get_strings( $this->footprint['user_meta_global_prefixes'] ?? null ) as $prefix ) { - $this->delete_meta_keys( 'user', $this->get_names_by_prefix_outside_table_prefix( $wpdb->usermeta, 'meta_key', $prefix ) ); + $this->delete_meta_keys( 'user', $this->get_names_by_prefix( $wpdb->usermeta, 'meta_key', $prefix ) ); } } @@ -471,34 +471,6 @@ protected function get_names_by_prefix( string $table, string $column, string $p return \array_values( \array_filter( $names, static fn ( mixed $name ): bool => \is_string( $name ) && \str_starts_with( $name, $prefix ) ) ); } - /** - * Returns the column's distinct values that start with the prefix, leaving out those that start with the table prefix when the prefix heads it. - * - * WordPress names each user's roles on a site and the site's role definitions after the table prefix, so a sweep of names without a site prefix would delete them where the table prefix starts with the plugin's. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @global \wpdb $wpdb The WordPress database connection. - * - * @param string $table The table. - * @param string $column The column. - * @param string $prefix The prefix, matched byte for byte. - * - * @return list - */ - protected function get_names_by_prefix_outside_table_prefix( string $table, string $column, string $prefix ): array { - global $wpdb; - - $names = $this->get_names_by_prefix( $table, $column, $prefix ); - $table_prefix = $wpdb->base_prefix; - if ( ! \str_starts_with( $table_prefix, $prefix ) ) { - return $names; - } - - return \array_values( \array_filter( $names, static fn ( string $name ): bool => ! \str_starts_with( $name, $table_prefix ) ) ); - } - /** * Returns the ids of the rows whose column holds exactly the value, and records a failed read. * diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php index 26fe4f0..2cd3aad 100644 --- a/tests/Integration/Core/Uninstall/UninstallerTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerTest.php @@ -79,40 +79,6 @@ public function test_a_consenting_run_without_a_current_user_deletes_exactly_the self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) ); } - public function test_the_prefix_sweeps_keep_what_wordpress_names_after_a_table_prefix_the_footprint_prefix_heads(): void { - global $wpdb; - $p = $this->prefix; - $table = $wpdb->base_prefix; - update_option( "{$p}_remove_data", 'yes' ); - $this->seed_footprint(); - update_option( "{$p}_tbl_user_roles", 'x' ); - update_option( "{$p}_tbl_acme_a", 'x' ); - add_user_meta( $this->ids['user'], "{$p}_tbl_capabilities", 'x' ); - add_user_meta( $this->ids['user'], "{$p}_tbl_acme_a", 'x' ); - - // WordPress names a site's roles and its users' capabilities after the table prefix, which the engine reads from wpdb. - $wpdb->base_prefix = "{$p}_tbl_"; - try { - Uninstaller::run( - self::PLUGIN_FILE, - array( - 'option_prefixes' => array( "{$p}_cache_", "{$p}_t", "{$p}_tbl_acme_" ), - 'user_meta_global_prefixes' => array( "{$p}_global_", "{$p}_t", "{$p}_tbl_acme_" ), - ), - array( 'consent' => array( 'option' => "{$p}_remove_data" ) ) - ); - } finally { - $wpdb->base_prefix = $table; - } - - self::assertTrue( $this->option_exists( "{$p}_tbl_user_roles" ) ); - self::assertTrue( $this->user_meta_exists( "{$p}_tbl_capabilities" ) ); - self::assertFalse( $this->option_exists( "{$p}_tbl_acme_a" ) ); - self::assertFalse( $this->user_meta_exists( "{$p}_tbl_acme_a" ) ); - self::assertFalse( $this->option_exists( "{$p}_cache_a" ) ); - self::assertFalse( $this->user_meta_exists( "{$p}_global_a" ) ); - } - public function test_the_cleanup_runs_first_while_the_footprint_and_the_consent_row_are_stored(): void { $consent_option = "{$this->prefix}_cache_remove_data"; update_option( $consent_option, 'yes' );