diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 3ca00dd..cb86435 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -2,6 +2,7 @@ src wp-lit-mcp.php + uninstall.php /vendor/* diff --git a/src/Lifecycle.php b/src/Lifecycle.php index b19b281..7b296c5 100644 --- a/src/Lifecycle.php +++ b/src/Lifecycle.php @@ -4,12 +4,15 @@ declare(strict_types=1); namespace LIT\MCP; +use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; +use WPMedia\MCP\OAuth\Bootstrap as OAuthBootstrap; /** - * Activation and deactivation hooks (registered in wp-lit-mcp.php). + * Activation and deactivation hooks (registered in wp-lit-mcp.php). Uninstall + * lives in uninstall.php. */ final class Lifecycle { @@ -18,10 +21,20 @@ final class Lifecycle $log = new ChangeLog(); (new ChangeLogInstaller($log))->install(); (new ChangeLogCleanup($log, new Limits()))->ensureScheduled(); + + if (class_exists(OAuthBootstrap::class)) { + // mcp-oauth's public API: rewrite its /oauth and /.well-known rules on the next init. + OAuthBootstrap::schedule_rewrite_flush(); + } } public static function deactivate(): void { wp_clear_scheduled_hook(ChangeLogCleanup::HOOK); + delete_transient(ConnectionStatus::ENDPOINT_TRANSIENT); + // WordPress regenerates the rules on the next request – without those of this now + // inactive plugin. Calling flush_rewrite_rules() here instead would rewrite them + // including our rules, because the plugin is still loaded during this request. + delete_option('rewrite_rules'); } } diff --git a/src/Uninstaller.php b/src/Uninstaller.php new file mode 100644 index 0000000..60b98d9 --- /dev/null +++ b/src/Uninstaller.php @@ -0,0 +1,62 @@ + 'ids', 'number' => 0]) as $site_id) { + switch_to_blog((int) $site_id); + self::removeSiteData(); + restore_current_blog(); + } + } + + public static function removeSiteData(): void + { + global $wpdb; + + foreach (self::OPTIONS as $option) { + delete_option($option); + } + delete_transient(self::TRANSIENT); + wp_clear_scheduled_hook(self::CRON_HOOK); + + $table = $wpdb->prefix . self::TABLE; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name, not user input. + $wpdb->query("DROP TABLE IF EXISTS {$table}"); + } +} diff --git a/tests/Unit/LifecycleTest.php b/tests/Unit/LifecycleTest.php index 2082d4c..850c57f 100644 --- a/tests/Unit/LifecycleTest.php +++ b/tests/Unit/LifecycleTest.php @@ -16,20 +16,23 @@ final class LifecycleTest extends TestCase parent::tearDown(); } - public function testActivateInstallsTheTableAndSchedulesTheCleanup(): void + public function testActivateInstallsTableSchedulesCleanupAndRequestsOauthRewriteFlush(): void { $GLOBALS['wpdb'] = Mockery::mock(\wpdb::class)->makePartial(); Functions\expect('dbDelta')->once(); Functions\expect('update_option')->once()->with('lit_mcp_db_version', '1'); Functions\when('wp_next_scheduled')->justReturn(false); Functions\expect('wp_schedule_event')->once(); + Functions\expect('delete_option')->once()->with('wpmedia_mcp_oauth_rewrite_version'); Lifecycle::activate(); } - public function testDeactivateUnschedulesTheCleanup(): void + public function testDeactivateRemovesCronTransientAndStoredRewriteRules(): void { Functions\expect('wp_clear_scheduled_hook')->once()->with('lit_mcp_log_cleanup'); + Functions\expect('delete_transient')->once()->with('lit_mcp_endpoint_check'); + Functions\expect('delete_option')->once()->with('rewrite_rules'); Lifecycle::deactivate(); } diff --git a/tests/Unit/UninstallTest.php b/tests/Unit/UninstallTest.php new file mode 100644 index 0000000..03cd509 --- /dev/null +++ b/tests/Unit/UninstallTest.php @@ -0,0 +1,51 @@ +makePartial(); + $db->expects('query')->twice()->with('DROP TABLE IF EXISTS wp_lit_mcp_log'); + $GLOBALS['wpdb'] = $db; + Functions\when('is_multisite')->justReturn(true); + Functions\when('get_sites')->justReturn([1, 2]); + Functions\expect('switch_to_blog')->twice(); + Functions\expect('restore_current_blog')->twice(); + Functions\expect('delete_transient')->twice()->with(ConnectionStatus::ENDPOINT_TRANSIENT); + Functions\expect('wp_clear_scheduled_hook')->twice()->with(ChangeLogCleanup::HOOK); + $deleted = []; + Functions\when('delete_option')->alias(static function (string $option) use (&$deleted): bool { + $deleted[] = $option; + return true; + }); + + require dirname(__DIR__, 2) . '/uninstall.php'; + + $own = [Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION]; + self::assertSame(array_merge($own, $own), $deleted); + self::assertNotContains('mcp_jwt_secret', $deleted); + self::assertNotContains('wpmedia_mcp_oauth_rewrite_version', $deleted); + } +} diff --git a/tests/Unit/UninstallerTest.php b/tests/Unit/UninstallerTest.php new file mode 100644 index 0000000..e23435b --- /dev/null +++ b/tests/Unit/UninstallerTest.php @@ -0,0 +1,69 @@ +makePartial(); + $db->expects('query')->once()->with('DROP TABLE IF EXISTS wp_lit_mcp_log'); + $GLOBALS['wpdb'] = $db; + Functions\when('is_multisite')->justReturn(false); + Functions\expect('switch_to_blog')->never(); + Functions\expect('restore_current_blog')->never(); + Functions\expect('delete_transient')->once()->with(ConnectionStatus::ENDPOINT_TRANSIENT); + Functions\expect('wp_clear_scheduled_hook')->once()->with(ChangeLogCleanup::HOOK); + $deleted = []; + Functions\when('delete_option')->alias(static function (string $option) use (&$deleted): bool { + $deleted[] = $option; + return true; + }); + + Uninstaller::run(); + + self::assertSame( + [Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION], + $deleted + ); + } + + public function testRunRemovesDataOnEverySiteOfAMultisiteNetwork(): void + { + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->expects('query')->twice()->with('DROP TABLE IF EXISTS wp_lit_mcp_log'); + $GLOBALS['wpdb'] = $db; + Functions\when('is_multisite')->justReturn(true); + Functions\when('get_sites')->justReturn([1, 2]); + Functions\expect('switch_to_blog')->twice(); + Functions\expect('restore_current_blog')->twice(); + Functions\expect('delete_transient')->twice()->with(ConnectionStatus::ENDPOINT_TRANSIENT); + Functions\expect('wp_clear_scheduled_hook')->twice()->with(ChangeLogCleanup::HOOK); + Functions\when('delete_option')->justReturn(true); + + Uninstaller::run(); + } +} diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..cb6fd9e --- /dev/null +++ b/uninstall.php @@ -0,0 +1,27 @@ +