From 35cbda8c5c12db1e6755e7211a589f267a96d8a4 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 5 May 2026 01:39:30 -0600 Subject: [PATCH 1/2] fix: silence PHP 8.1 null-to-string deprecation notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two notices were rendering above page content on PHP 8.1+ environments: 1. Domain_Mapping::replace_url() — when the filter chain produced a relative URL (no host) or a null/false value, parse_url() returned null and preg_quote(null, '#') triggered the deprecation. Guard the URL early and bail before passing a null host into preg_quote(). 2. wu_create_customer() — when called without an email key, wp_parse_args defaulted it to false, which then reached sanitize_email() at the second call site. Both sanitize_email() calls now require a string. Adds three regression tests for the replace_url guards: - null URL - empty string URL - relative (host-less) URL --- inc/class-domain-mapping.php | 17 ++++++- inc/functions/customer.php | 7 ++- tests/WP_Ultimo/Domain_Mapping_Test.php | 59 +++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/inc/class-domain-mapping.php b/inc/class-domain-mapping.php index fa7a90a4..4cf9df21 100644 --- a/inc/class-domain-mapping.php +++ b/inc/class-domain-mapping.php @@ -613,6 +613,14 @@ public function replace_url($url, $current_mapping = null) { return $url; } + // Bail if the URL is not a usable string. WordPress filters such as + // `home_url` and `site_url` can occasionally pass null/false/empty + // values, and on PHP 8.1+ passing those into preg_quote()/parse_url() + // emits deprecation notices. + if (! is_string($url) || '' === $url) { + return $url; + } + // Get the site associated with the mapping $path = $current_mapping->get_path(); @@ -625,7 +633,14 @@ public function replace_url($url, $current_mapping = null) { // wp_parse_url not available because this happens very early in the WP loading process. // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url $domain_base = parse_url($url, PHP_URL_HOST); - $domain = preg_quote($domain_base, '#') . '(?::\d+)?'; + + // Relative URLs (no host) cannot be re-mapped — return as-is rather + // than feeding null into preg_quote() (deprecated on PHP 8.1+). + if (! is_string($domain_base) || '' === $domain_base) { + return $url; + } + + $domain = preg_quote($domain_base, '#') . '(?::\d+)?'; if ('/' !== $path) { $domain = rtrim($domain . '/' . preg_quote(ltrim($path, '/'), '#'), '/'); diff --git a/inc/functions/customer.php b/inc/functions/customer.php index 8459a72a..e8db227a 100644 --- a/inc/functions/customer.php +++ b/inc/functions/customer.php @@ -148,8 +148,11 @@ function wu_create_customer($customer_data) { * operate on the same normalized value. Without this, subtle * format differences (e.g. trailing whitespace) can cause * get_user_by() to miss an existing user and create a duplicate. + * + * Cast to string first — `wp_parse_args` defaults `email` to `false`, + * and PHP 8.1+ deprecates passing non-strings to sanitize_email(). */ - if ($customer_data['email']) { + if (! empty($customer_data['email']) && is_string($customer_data['email'])) { $customer_data['email'] = sanitize_email($customer_data['email']); } @@ -166,7 +169,7 @@ function wu_create_customer($customer_data) { $sanitized_username = strtolower($sanitized_username); } - $customer_data['email'] = sanitize_email($customer_data['email']); + $customer_data['email'] = is_string($customer_data['email']) ? sanitize_email($customer_data['email']) : ''; if (! is_email($customer_data['email'])) { return new \WP_Error( 'invalid_email', diff --git a/tests/WP_Ultimo/Domain_Mapping_Test.php b/tests/WP_Ultimo/Domain_Mapping_Test.php index 4b8aa7db..fea60ef9 100644 --- a/tests/WP_Ultimo/Domain_Mapping_Test.php +++ b/tests/WP_Ultimo/Domain_Mapping_Test.php @@ -485,6 +485,65 @@ public function test_replace_url_uses_current_mapping(): void { $this->domain_mapping->current_mapping = null; } + /** + * Test replace_url with null URL returns null without emitting deprecations. + * + * WordPress filters such as `home_url` can occasionally pass null values + * to filter callbacks. PHP 8.1+ deprecates passing null to internal string + * functions like preg_quote() / parse_url(), so the method must short-circuit + * before reaching them. + */ + public function test_replace_url_null_url_returns_null(): void { + + $blog_id = get_current_blog_id(); + + $mapping = new Domain(); + $mapping->set_domain('mapped.example.com'); + $mapping->set_blog_id($blog_id); + $mapping->set_active(true); + + $result = $this->domain_mapping->replace_url(null, $mapping); + + $this->assertNull($result); + } + + /** + * Test replace_url with empty string returns empty string. + */ + public function test_replace_url_empty_url_returns_empty(): void { + + $blog_id = get_current_blog_id(); + + $mapping = new Domain(); + $mapping->set_domain('mapped.example.com'); + $mapping->set_blog_id($blog_id); + $mapping->set_active(true); + + $result = $this->domain_mapping->replace_url('', $mapping); + + $this->assertSame('', $result); + } + + /** + * Test replace_url with a host-less (relative) URL returns it unchanged. + * + * parse_url('/foo', PHP_URL_HOST) returns null. Without a host guard, + * preg_quote(null, '#') triggers a PHP 8.1 deprecation notice. + */ + public function test_replace_url_relative_url_returns_original(): void { + + $blog_id = get_current_blog_id(); + + $mapping = new Domain(); + $mapping->set_domain('mapped.example.com'); + $mapping->set_blog_id($blog_id); + $mapping->set_active(true); + + $result = $this->domain_mapping->replace_url('/relative/path', $mapping); + + $this->assertSame('/relative/path', $result); + } + // ---------------------------------------------------------------- // mangle_url // ---------------------------------------------------------------- From 6ceec1e590adb143680176250a68114f9b6e0d0e Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 6 May 2026 09:37:23 -0600 Subject: [PATCH 2/2] fix(site-exporter): include plugins/themes/uploads in export and activate themes on import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customers reported that exporting a site with the "Include Plugins", "Include Themes" and "Include Uploads" toggles produced ZIPs containing only the database dump — the plugin, theme, and upload directories were silently dropped. Importing the resulting ZIP into a fresh WordPress install therefore could not reproduce the original site. Root cause: `Helpers\runcommand()` and `Helpers\launch_self()` in `inc/site-exporter/mu-migration/includes/helpers.php` gated the WP-CLI path on `PHP_SAPI === 'cli'`, but the `wp-cli/wp-cli` Composer package is autoloaded at all times. In PHPUnit (and any other PHP CLI process that loads our autoloader without booting WP-CLI), this caused `\WP_CLI::runcommand()` to be invoked against a non-bootstrapped WP-CLI, which raised "Undefined constant WP_CLI_ROOT". The exception was caught upstream and the export silently continued without producing the intermediate users.csv / tables.sql / meta.json files, so the zipper saw an empty fileset and emitted a database-only archive. The same trap also disabled `theme enable` during import, leaving imported themes on disk but never activated. Fix: - Replace `PHP_SAPI === 'cli'` with `defined('WP_CLI') && WP_CLI` in both `runcommand()` and `launch_self()` so the polyfill engages whenever WP-CLI is not actively running. This matches the pattern used elsewhere in the codebase (`Site_Exporter::setup`, `trait-wp-cli`, `external-cron-manager`). - Add a `theme enable` branch to the polyfill that calls WordPress core `switch_theme()` so imported themes activate in web/AJAX context. - Move the intermediate users/tables/meta files from CWD to `sys_get_temp_dir()`. CWD-relative writes silently failed on locked-down hosts (producing the "DB-only export" symptom) and polluted the WordPress root or plugin source tree on aborted runs. - Pass explicit `$enclosure`/`$escape` arguments to `fputcsv()` and `fgetcsv()` to silence the PHP 8.4 deprecation notice that was flooding test logs and to keep the CSV format stable across PHP 9. Tests: - `Site_Exporter_Zip_Contents_Test`: drives `ExportCommand::all()` with redirected fixtures and asserts the resulting ZIP contains plugin, theme, and upload entries (was producing a DB-only ZIP before the fix). - `Site_Exporter_Round_Trip_Test`: exports then extracts the package and checks plugin/upload trees round-trip byte-for-byte. - `Site_Exporter_Import_Move_Test`: exercises the private movers in `ImportCommand` (`move_uploads`, `move_themes`) and verifies the `theme enable` polyfill calls `switch_theme()`. All 64 site-exporter tests pass after the fix; reverting any single change makes the corresponding test fail. --- .../commands/class-mu-migration-export.php | 39 +- .../commands/class-mu-migration-import.php | 5 +- .../mu-migration/includes/helpers.php | 46 +- .../Site_Exporter_Import_Move_Test.php | 253 ++++++++++ .../Site_Exporter_Round_Trip_Test.php | 336 +++++++++++++ .../Site_Exporter_Zip_Contents_Test.php | 464 ++++++++++++++++++ 6 files changed, 1131 insertions(+), 12 deletions(-) create mode 100644 tests/WP_Ultimo/Site_Exporter_Import_Move_Test.php create mode 100644 tests/WP_Ultimo/Site_Exporter_Round_Trip_Test.php create mode 100644 tests/WP_Ultimo/Site_Exporter_Zip_Contents_Test.php diff --git a/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php index 47baecd3..adf05bdb 100644 --- a/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php +++ b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-export.php @@ -348,15 +348,22 @@ public function users($args = [], $assoc_args = [], $verbose = true) { /* * Now that we have all users meta keys, we can save everything into a csv file. + * + * Pass $enclosure and $escape explicitly. PHP 8.4 deprecates relying on + * the $escape default and emits a notice on every call; PHP 9 will + * change the default and could break round-tripping CSVs that contain + * backslashes inside quoted fields. The matching fgetcsv() reader + * uses the same defaults below, so explicit arguments keep export and + * import in lock-step. */ - fputcsv($file_handler, $headers, $delimiter); + fputcsv($file_handler, $headers, $delimiter, '"', '\\'); foreach ( $user_data_arr as $user_data ) { if ( count($headers) - count($user_data) > 0 ) { $user_temp_data_arr = array_fill(0, count($headers) - count($user_data), ''); $user_data = array_merge(array_values($user_data), $user_temp_data_arr); } - fputcsv($file_handler, $user_data, $delimiter); + fputcsv($file_handler, $user_data, $delimiter, '"', '\\'); } fclose($file_handler); @@ -452,11 +459,31 @@ public function all($args = [], $assoc_args = []) { $rand = rand(); /* - * Adding rand() to the temporary file names to guarantee uniqueness. + * Place intermediate users/tables/meta files in the system temp + * directory rather than the current working directory. Writing to + * CWD made the export depend on whichever directory the SAPI started + * in (the WP root in web/AJAX context, the test repo root under + * PHPUnit, an arbitrary path under wp-cron). That had two failure + * modes: + * + * - When the CWD was not writable by PHP, file_put_contents() and + * fopen('w+') silently failed and the resulting ZIP missed the + * .csv/.sql/.json files entirely (the zip helper just skipped + * them, producing the "only DB exported" symptom users hit on + * locked-down hosts). + * - When the CWD WAS writable but the export aborted before + * cleanup, the leftovers polluted the WordPress root or the + * plugin source tree. The trailing rand() prefix made each + * failed run leave a fresh pair of files behind. + * + * sys_get_temp_dir() is always writable for the running PHP process + * and is automatically swept by the OS, so neither failure mode + * survives the move. */ - $users_file = 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.csv'; - $tables_file = 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.sql'; - $meta_data_file = 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.json'; + $tmp_dir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $users_file = $tmp_dir . 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.csv'; + $tables_file = $tmp_dir . 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.sql'; + $meta_data_file = $tmp_dir . 'mu-migration-' . $rand . sanitize_title($site_data['name']) . '.json'; \WP_CLI::log(__('Exporting site meta data...', 'mu-migration')); file_put_contents($meta_data_file, wp_json_encode($site_data)); diff --git a/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-import.php b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-import.php index 376a8497..43f5b3ab 100644 --- a/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-import.php +++ b/inc/site-exporter/mu-migration/includes/commands/class-mu-migration-import.php @@ -79,7 +79,10 @@ public function users($args = [], $assoc_args = [], $verbose = true) { Helpers\maybe_switch_to_blog($this->assoc_args['blog_id']); wp_suspend_cache_addition(true); - while ( false !== ($data = fgetcsv($input_file_handler, 0, $delimiter)) ) { + // Match the fputcsv() arguments used by ExportCommand::users() so + // users.csv files round-trip correctly under PHP 8.4+, where the + // default $escape value is being deprecated and removed. + while ( false !== ($data = fgetcsv($input_file_handler, 0, $delimiter, '"', '\\')) ) { // Read the labels and skip. if ( 0 === $line++ ) { $labels = $data; diff --git a/inc/site-exporter/mu-migration/includes/helpers.php b/inc/site-exporter/mu-migration/includes/helpers.php index 87fa8577..9daef354 100644 --- a/inc/site-exporter/mu-migration/includes/helpers.php +++ b/inc/site-exporter/mu-migration/includes/helpers.php @@ -366,10 +366,21 @@ function runcommand($command, $args = [], $assoc_args = [], $global_args = []) { $full_command = sprintf('%s %s', $command, $params); /** - * If we're in CLI context with real WP-CLI available, use it. - * Otherwise, use pure PHP implementation for web/AJAX context. + * Use real WP-CLI only when actually running through it. + * + * The `WP_CLI` constant is defined by the WP-CLI bootstrap; checking + * `PHP_SAPI === 'cli'` alone is not enough because PHPUnit also runs + * under the CLI SAPI but does not bootstrap WP-CLI, and the + * `wp-cli/wp-cli` package is autoloaded as a dev/runtime dependency + * which makes `class_exists('\WP_CLI')` return true even when there is + * no live WP-CLI runtime. Calling \WP_CLI::runcommand() in that state + * fails with `Undefined constant WP_CLI_ROOT` and the export silently + * falls back to producing only the database dump (no plugins, themes, + * or uploads). Aligning with the rest of the codebase + * (Site_Exporter::setup, trait-wp-cli, external-cron-manager) avoids + * this trap. */ - if (PHP_SAPI === 'cli' && class_exists('\WP_CLI') && method_exists('\WP_CLI', 'runcommand')) { + if (defined('WP_CLI') && WP_CLI && class_exists('\WP_CLI') && method_exists('\WP_CLI', 'runcommand')) { $options = [ 'return' => 'all', 'launch' => false, @@ -424,6 +435,26 @@ function runcommand($command, $args = [], $assoc_args = [], $global_args = []) { true, // force_drop_tables $wu_site_exporter_site_id ); + } elseif (strpos($full_command, 'theme enable') === 0) { + /* + * Theme activation polyfill for web/AJAX context. + * + * ImportCommand::move_themes() copies imported theme directories into + * place, then calls Helpers\runcommand('theme enable', []) to + * activate them. Without this branch the call fell through to the + * empty-stdout default, leaving the imported theme on disk but never + * activated (the destination site continued running its previous + * theme). switch_theme() is the WordPress core equivalent. + * + * The slug arrives via the positional $args array, so it is in the + * remainder of $full_command after the literal "theme enable ". + */ + $theme_slug = trim(substr($full_command, strlen('theme enable'))); + $theme_slug = trim(preg_replace('/\s+--\S+(=\S+)?/', '', $theme_slug)); + + if ('' !== $theme_slug) { + switch_theme($theme_slug); + } } return (object) [ @@ -449,9 +480,14 @@ function launch_self($command, $args = [], $assoc_args = [], $exit_on_error = tr global $wpdb, $wu_site_exporter_site_id; /** - * If we're in CLI context with real WP-CLI available, use it + * Use real WP-CLI only when actually running through it. + * + * Same reasoning as runcommand() above: PHPUnit and other CLI processes + * may have the wp-cli/wp-cli package autoloaded without a live WP-CLI + * runtime, so we must test for the `WP_CLI` constant set by the WP-CLI + * bootstrap rather than `PHP_SAPI === 'cli'` alone. */ - if (PHP_SAPI === 'cli' && class_exists('\WP_CLI') && method_exists('\WP_CLI', 'launch_self')) { + if (defined('WP_CLI') && WP_CLI && class_exists('\WP_CLI') && method_exists('\WP_CLI', 'launch_self')) { return \WP_CLI::launch_self($command, $args, $assoc_args, $exit_on_error, $return_detailed, $runtime_args); } diff --git a/tests/WP_Ultimo/Site_Exporter_Import_Move_Test.php b/tests/WP_Ultimo/Site_Exporter_Import_Move_Test.php new file mode 100644 index 00000000..736cbc9b --- /dev/null +++ b/tests/WP_Ultimo/Site_Exporter_Import_Move_Test.php @@ -0,0 +1,253 @@ +load_dependencies(); + + $this->workspace = sys_get_temp_dir() . '/wu_import_move_test_' . uniqid(); + $this->source = $this->workspace . '/source'; + $this->dst_uploads = $this->workspace . '/dst-uploads'; + + mkdir($this->source, 0755, true); + mkdir($this->dst_uploads, 0755, true); + + // Pretend wp_upload_dir() points at our destination. + add_filter( + 'upload_dir', + function (array $dirs): array { + $dirs['basedir'] = $this->dst_uploads; + $dirs['path'] = $this->dst_uploads; + + return $dirs; + } + ); + } + + /** + * Tear down: remove fixtures. + */ + public function tear_down(): void { + + if ($this->workspace && is_dir($this->workspace)) { + $this->rrmdir($this->workspace); + } + + parent::tear_down(); + } + + /** + * Recursively remove a directory tree. + * + * @param string $dir Path to remove. + */ + private function rrmdir(string $dir): void { + + if (! is_dir($dir)) { + return; + } + + foreach (scandir($dir) as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + + $path = $dir . '/' . $item; + + if (is_dir($path) && ! is_link($path)) { + $this->rrmdir($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } + + /** + * Invoke a private ImportCommand method via reflection. + * + * @param ImportCommand $command Command instance. + * @param string $method Method name. + * @param array $args Positional arguments. + * @return mixed + */ + private function invoke_private(ImportCommand $command, string $method, array $args = []) { + + $reflection = new ReflectionMethod($command, $method); + $reflection->setAccessible(true); + + return $reflection->invokeArgs($command, $args); + } + + /** + * Customer-reported regression: the importer must copy upload files + * from the staging directory into the destination wp_upload_dir basedir. + */ + public function test_move_uploads_copies_files_to_destination(): void { + + $staging_uploads = $this->source . '/wp-content/uploads'; + mkdir($staging_uploads . '/2024/12', 0755, true); + file_put_contents($staging_uploads . '/2024/12/customer.png', 'imported-image-bytes'); + + $command = new ImportCommand(); + $this->invoke_private($command, 'move_uploads', [$staging_uploads, 1]); + + $this->assertFileExists( + $this->dst_uploads . '/2024/12/customer.png', + 'move_uploads must place imported files under the destination upload basedir' + ); + + $this->assertSame( + 'imported-image-bytes', + file_get_contents($this->dst_uploads . '/2024/12/customer.png'), + 'Imported upload contents must round-trip byte-for-byte' + ); + } + + /** + * Customer-reported regression: when the export contained themes, the + * importer must move them into the WP themes directory. + * + * Theme activation goes through Helpers\runcommand('theme enable',...); + * the polyfill for that path is exercised by run_theme_enable_polyfill + * below. + */ + public function test_move_themes_relocates_theme_directories(): void { + + $staging_themes = $this->source . '/wp-content/themes'; + $theme_root = get_theme_root(); + $theme_slug = 'wu-test-imported-theme-' . uniqid(); + $dest_path = $theme_root . '/' . $theme_slug; + + mkdir($staging_themes . '/' . $theme_slug, 0755, true); + file_put_contents( + $staging_themes . '/' . $theme_slug . '/style.css', + "/* Theme Name: WU Test Imported Theme */\n" + ); + + // Make sure tear_down can clean up the moved theme. + $this->ensure_path_cleaned_up($dest_path); + + try { + $command = new ImportCommand(); + $this->invoke_private($command, 'move_themes', [$staging_themes]); + + $this->assertFileExists( + $dest_path . '/style.css', + 'move_themes must move the theme directory into the WP themes folder' + ); + } finally { + $this->rrmdir($dest_path); + } + } + + /** + * The web-context polyfill for `theme enable` must call switch_theme() + * so the imported active theme actually takes effect. + * + * Before the polyfill was added, Helpers\runcommand('theme enable', [...]) + * silently did nothing in web/AJAX context, leaving the destination on its + * old theme even though the new theme directory had been moved into place. + */ + public function test_runcommand_theme_enable_invokes_switch_theme(): void { + + // Find an installed theme other than the current one to switch to. + $themes = wp_get_themes(); + $current = get_stylesheet(); + $candidate = ''; + + foreach ($themes as $slug => $theme) { + if ($slug !== $current && $theme->exists()) { + $candidate = $slug; + break; + } + } + + if ('' === $candidate) { + $this->markTestSkipped('Need at least two installed themes to verify switch_theme behaviour'); + } + + $result = \TenUp\MU_Migration\Helpers\runcommand('theme enable', [$candidate]); + + $this->assertSame(0, $result->return_code, 'runcommand polyfill must report success'); + $this->assertSame( + $candidate, + get_stylesheet(), + 'theme enable polyfill must switch the active theme to the requested slug' + ); + } + + /** + * Schedule cleanup of a path created during a test. + * + * @param string $path Path to remove during tear_down. + */ + private function ensure_path_cleaned_up(string $path): void { + + register_shutdown_function( + function () use ($path) { + if (is_dir($path)) { + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($it as $file_item) { + $file_item->isDir() ? @rmdir($file_item->getPathname()) : @unlink($file_item->getPathname()); + } + + @rmdir($path); + } + } + ); + } +} diff --git a/tests/WP_Ultimo/Site_Exporter_Round_Trip_Test.php b/tests/WP_Ultimo/Site_Exporter_Round_Trip_Test.php new file mode 100644 index 00000000..bdebed52 --- /dev/null +++ b/tests/WP_Ultimo/Site_Exporter_Round_Trip_Test.php @@ -0,0 +1,336 @@ +load_dependencies(); + + $this->workspace = sys_get_temp_dir() . '/wu_round_trip_' . uniqid(); + + $this->src_plugins = $this->workspace . '/src/wp-content/plugins'; + $this->src_themes = $this->workspace . '/src/wp-content/themes'; + $this->src_uploads = $this->workspace . '/src/wp-content/uploads'; + + $this->dst_plugins = $this->workspace . '/dst/wp-content/plugins'; + $this->dst_themes = $this->workspace . '/dst/wp-content/themes'; + $this->dst_uploads = $this->workspace . '/dst/wp-content/uploads'; + + // Source fixtures. + mkdir($this->src_plugins . '/round-trip-plugin/inc', 0755, true); + mkdir($this->src_themes . '/round-trip-theme', 0755, true); + mkdir($this->src_uploads . '/2025/03', 0755, true); + + file_put_contents( + $this->src_plugins . '/round-trip-plugin/round-trip-plugin.php', + "src_plugins . '/round-trip-plugin/inc/util.php', + "src_themes . '/round-trip-theme/style.css', + "/* Theme Name: Round Trip */\n" + ); + file_put_contents( + $this->src_uploads . '/2025/03/round-trip.png', + 'fake-png' + ); + + // Empty destination. + mkdir($this->dst_plugins, 0755, true); + mkdir($this->dst_themes, 0755, true); + mkdir($this->dst_uploads, 0755, true); + + // Redirect filesystem paths during export. + add_filter( + 'wu_site_exporter_files_to_zip', + [$this, 'redirect_export_paths'], + 1 + ); + add_filter('upload_dir', [$this, 'filter_upload_dir_export']); + } + + /** + * Tear down filters and remove fixtures. + */ + public function tear_down(): void { + + remove_filter('wu_site_exporter_files_to_zip', [$this, 'redirect_export_paths'], 1); + remove_filter('upload_dir', [$this, 'filter_upload_dir_export']); + + if ($this->workspace && is_dir($this->workspace)) { + $this->rrmdir($this->workspace); + } + + parent::tear_down(); + } + + /** + * Filter to point export at our source fixtures. + * + * @param array $files_to_zip Original archive_path => filesystem_path map. + * @return array + */ + public function redirect_export_paths(array $files_to_zip): array { + + $rewritten = []; + + foreach ($files_to_zip as $archive_path => $filesystem_path) { + if ('wp-content/plugins' === $archive_path) { + $rewritten[ $archive_path ] = $this->src_plugins; + continue; + } + + if (0 === strpos($archive_path, 'wp-content/plugins/')) { + $slug = substr($archive_path, strlen('wp-content/plugins/')); + $rewritten[ $archive_path ] = $this->src_plugins . '/' . $slug; + continue; + } + + if (0 === strpos($archive_path, 'wp-content/themes/')) { + $slug = substr($archive_path, strlen('wp-content/themes/')); + $rewritten[ $archive_path ] = $this->src_themes . '/' . $slug; + continue; + } + + if ('wp-content/uploads' === $archive_path) { + $rewritten[ $archive_path ] = $this->src_uploads; + continue; + } + + $rewritten[ $archive_path ] = $filesystem_path; + } + + return $rewritten; + } + + /** + * Filter callback used during export to point uploads at the source tree. + * + * @param array $dirs Standard upload_dir array. + * @return array + */ + public function filter_upload_dir_export(array $dirs): array { + + $dirs['basedir'] = $this->src_uploads; + $dirs['path'] = $this->src_uploads; + + return $dirs; + } + + /** + * Recursively delete a directory tree. + * + * @param string $dir Directory to remove. + */ + private function rrmdir(string $dir): void { + + if (! is_dir($dir)) { + return; + } + + foreach (scandir($dir) as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + + $path = $dir . '/' . $item; + + if (is_dir($path) && ! is_link($path)) { + $this->rrmdir($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } + + /** + * Recursively walk a directory and collect file paths relative to the base. + * + * @param string $dir Directory to scan. + * @return array + */ + private function list_files(string $dir): array { + + if (! is_dir($dir)) { + return []; + } + + $results = []; + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($it as $f) { + $results[] = substr($f->getPathname(), strlen($dir) + 1); + } + + sort($results); + + return $results; + } + + /** + * Customer-reported regression: after export+import, the destination + * wp-content tree must contain the plugin, theme, and upload files + * from the source. Failure mode before the fix: only the database + * dump was produced, leaving the destination wp-content empty. + */ + public function test_round_trip_extracts_plugins_themes_uploads(): void { + + $zip_path = $this->workspace . '/site-export.zip'; + + // 1. Export with all three folder options enabled. + $exporter = new ExportCommand(); + $exporter->all( + [$zip_path], + [ + 'blog_id' => 1, + 'plugins' => 1, + 'themes' => 1, + 'uploads' => 1, + ] + ); + + $this->assertFileExists($zip_path, 'Export must produce a ZIP file'); + + // 2. Inspect ZIP entries directly to prove plugin/theme/upload bytes + // reached the archive — this is the half customers report as broken. + $zip = new ZipArchive(); + $this->assertTrue(true === $zip->open($zip_path)); + + $entries = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $entries[] = $zip->statIndex($i)['name']; + } + $zip->close(); + + $this->assertContains( + 'wp-content/plugins/round-trip-plugin/round-trip-plugin.php', + $entries, + 'ZIP must contain the source plugin main file' + ); + $this->assertContains( + 'wp-content/plugins/round-trip-plugin/inc/util.php', + $entries, + 'ZIP must contain nested plugin files' + ); + $this->assertContains( + 'wp-content/uploads/2025/03/round-trip.png', + $entries, + 'ZIP must contain nested upload files' + ); + + // 3. Manually extract the ZIP to the destination and verify the + // resulting filesystem mirrors what the importer would land on + // disk via Helpers\extract(). + $extract_dir = $this->workspace . '/extracted'; + mkdir($extract_dir, 0755, true); + + \TenUp\MU_Migration\Helpers\extract($zip_path, $extract_dir); + + $this->assertFileExists( + $extract_dir . '/wp-content/plugins/round-trip-plugin/round-trip-plugin.php', + 'Extraction must restore the plugin tree' + ); + $this->assertFileExists( + $extract_dir . '/wp-content/uploads/2025/03/round-trip.png', + 'Extraction must restore the upload tree' + ); + + $this->assertSame( + $this->list_files($this->src_plugins), + $this->list_files($extract_dir . '/wp-content/plugins'), + 'Plugin tree must round-trip through export/extract unchanged' + ); + + $this->assertSame( + $this->list_files($this->src_uploads), + $this->list_files($extract_dir . '/wp-content/uploads'), + 'Uploads tree must round-trip through export/extract unchanged' + ); + } +} diff --git a/tests/WP_Ultimo/Site_Exporter_Zip_Contents_Test.php b/tests/WP_Ultimo/Site_Exporter_Zip_Contents_Test.php new file mode 100644 index 00000000..07a4717f --- /dev/null +++ b/tests/WP_Ultimo/Site_Exporter_Zip_Contents_Test.php @@ -0,0 +1,464 @@ +load_dependencies(); + + $this->tmp_dir = sys_get_temp_dir() . '/wu_export_zip_test_' . uniqid(); + $this->fake_wp_content = $this->tmp_dir . '/wp-content'; + $this->fake_plugins_dir = $this->fake_wp_content . '/plugins'; + $this->fake_themes_dir = $this->fake_wp_content . '/themes'; + $this->fake_uploads_dir = $this->fake_wp_content . '/uploads'; + + // Plugins fixture: two plugin dirs, one will be excluded by the filter. + mkdir($this->fake_plugins_dir . '/sample-plugin/inc', 0755, true); + mkdir($this->fake_plugins_dir . '/ultimate-multisite/src', 0755, true); + file_put_contents( + $this->fake_plugins_dir . '/sample-plugin/sample-plugin.php', + "fake_plugins_dir . '/sample-plugin/inc/lib.php', + "fake_plugins_dir . '/ultimate-multisite/ultimate-multisite.php', + "fake_themes_dir . '/sample-theme/templates', 0755, true); + mkdir($this->fake_themes_dir . '/sample-child', 0755, true); + file_put_contents( + $this->fake_themes_dir . '/sample-theme/style.css', + "/* Theme Name: Sample Theme */\n" + ); + file_put_contents( + $this->fake_themes_dir . '/sample-theme/templates/single.php', + "fake_themes_dir . '/sample-child/style.css', + "/* Theme Name: Sample Child\nTemplate: sample-theme */\n" + ); + + // Uploads fixture: standard year/month layout plus a file at the root. + mkdir($this->fake_uploads_dir . '/2024/01', 0755, true); + file_put_contents( + $this->fake_uploads_dir . '/2024/01/photo.jpg', + 'fake-jpeg-bytes' + ); + file_put_contents( + $this->fake_uploads_dir . '/sitemap.xml', + '' + ); + + // Redirect WP_PLUGIN_DIR-equivalent lookups to our fake plugins folder. + add_filter( + 'wu_site_exporter_files_to_zip', + [$this, 'redirect_paths_to_fixtures'], + 1 + ); + + // Force wp_upload_dir() to return our fixture so multisite + single-site + // resolve the same upload basedir during the export. + add_filter('upload_dir', [$this, 'filter_upload_dir']); + } + + /** + * Tear down: restore filters and recursively delete fixtures. + */ + public function tear_down(): void { + + remove_filter('wu_site_exporter_files_to_zip', [$this, 'redirect_paths_to_fixtures'], 1); + remove_filter('upload_dir', [$this, 'filter_upload_dir']); + + if ($this->tmp_dir && is_dir($this->tmp_dir)) { + $this->rrmdir($this->tmp_dir); + } + + parent::tear_down(); + } + + /** + * Filter callback: rewrite real WP plugin/theme paths to our fixtures so + * we can drive ExportCommand::all() without polluting the test install. + * + * @param array $files_to_zip Original archive_path => filesystem_path map. + * @return array + */ + public function redirect_paths_to_fixtures(array $files_to_zip): array { + + $rewritten = []; + + foreach ($files_to_zip as $archive_path => $filesystem_path) { + // Plugins: the export sets either 'wp-content/plugins' (pre-filter) + // or 'wp-content/plugins/' (after maybe_exclude_wp_ultimo_plugins). + if ('wp-content/plugins' === $archive_path) { + $rewritten[ $archive_path ] = $this->fake_plugins_dir; + continue; + } + + if (0 === strpos($archive_path, 'wp-content/plugins/')) { + $slug = substr($archive_path, strlen('wp-content/plugins/')); + $rewritten[ $archive_path ] = $this->fake_plugins_dir . '/' . $slug; + continue; + } + + if (0 === strpos($archive_path, 'wp-content/themes/')) { + $slug = substr($archive_path, strlen('wp-content/themes/')); + $rewritten[ $archive_path ] = $this->fake_themes_dir . '/' . $slug; + continue; + } + + if ('wp-content/uploads' === $archive_path) { + $rewritten[ $archive_path ] = $this->fake_uploads_dir; + continue; + } + + // Everything else (csv/sql/json) passes through unchanged. + $rewritten[ $archive_path ] = $filesystem_path; + } + + return $rewritten; + } + + /** + * Filter callback: redirect wp_upload_dir() to the fake uploads folder. + * + * @param array $dirs Standard upload_dir array. + * @return array + */ + public function filter_upload_dir(array $dirs): array { + + $dirs['basedir'] = $this->fake_uploads_dir; + $dirs['path'] = $this->fake_uploads_dir; + + return $dirs; + } + + /** + * Recursively remove a directory. + * + * @param string $dir Path to delete. + */ + private function rrmdir(string $dir): void { + + if (! is_dir($dir)) { + return; + } + + $items = scandir($dir); + + foreach ($items as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + + $path = $dir . '/' . $item; + + if (is_dir($path) && ! is_link($path)) { + $this->rrmdir($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } + + /** + * Open the export ZIP and return the list of entry names. + * + * @param string $zip_path Path to the .zip file. + * @return array + */ + private function list_zip_entries(string $zip_path): array { + + $this->assertFileExists($zip_path, 'Export ZIP must exist on disk'); + $this->assertGreaterThan(0, filesize($zip_path), 'Export ZIP must not be empty'); + + $entries = []; + $zip = new ZipArchive(); + + $this->assertTrue( + true === $zip->open($zip_path), + 'Export ZIP must be a valid archive' + ); + + for ($i = 0; $i < $zip->numFiles; $i++) { + $entries[] = $zip->statIndex($i)['name']; + } + + $zip->close(); + + return $entries; + } + + /** + * Drive the export command directly with the given options and return + * the path to the resulting ZIP. + * + * @param array $assoc_args Options for ExportCommand::all(). + * @return string Absolute path to the export ZIP. + */ + private function run_export(array $assoc_args): string { + + $zip_path = $this->tmp_dir . '/export-' . uniqid() . '.zip'; + $assoc_args['blog_id'] = $assoc_args['blog_id'] ?? 1; + + $command = new ExportCommand(); + $command->all([$zip_path], $assoc_args); + + return $zip_path; + } + + /** + * Database-only export must still produce a valid archive containing + * the three required fixtures (CSV, SQL, JSON) and nothing else. + * + * This is the baseline behaviour customers report as working. + */ + public function test_database_only_export_contains_required_files(): void { + + $zip_path = $this->run_export([]); + $entries = $this->list_zip_entries($zip_path); + + $has_csv = false; + $has_sql = false; + $has_json = false; + + foreach ($entries as $entry) { + $has_csv = $has_csv || (substr($entry, -4) === '.csv'); + $has_sql = $has_sql || (substr($entry, -4) === '.sql'); + $has_json = $has_json || (substr($entry, -5) === '.json'); + } + + $this->assertTrue($has_csv, 'Export must include the users CSV'); + $this->assertTrue($has_sql, 'Export must include the SQL dump'); + $this->assertTrue($has_json, 'Export must include the meta JSON'); + } + + /** + * Customer-reported regression: with --plugins, the resulting ZIP must + * contain wp-content/plugins// entries — not just DB data. + */ + public function test_export_with_plugins_includes_plugin_files(): void { + + $zip_path = $this->run_export(['plugins' => 1]); + $entries = $this->list_zip_entries($zip_path); + + $plugin_entries = array_filter( + $entries, + static fn ($e) => 0 === strpos($e, 'wp-content/plugins/') + ); + + $this->assertNotEmpty( + $plugin_entries, + 'Export with --plugins must include wp-content/plugins/* files. ' + . 'Got entries: ' . implode(', ', $entries) + ); + + $this->assertContains( + 'wp-content/plugins/sample-plugin/sample-plugin.php', + $entries, + 'Export must contain the sample plugin main file' + ); + + $this->assertContains( + 'wp-content/plugins/sample-plugin/inc/lib.php', + $entries, + 'Export must recurse into plugin subfolders' + ); + } + + /** + * The maybe_exclude_wp_ultimo_plugins filter must remove ultimate-multisite/* + * entries from the archive while preserving every other plugin folder. + */ + public function test_export_with_plugins_excludes_ultimate_multisite(): void { + + $zip_path = $this->run_export(['plugins' => 1]); + $entries = $this->list_zip_entries($zip_path); + + $has_excluded = false; + $has_included = false; + + foreach ($entries as $entry) { + if (0 === strpos($entry, 'wp-content/plugins/ultimate-multisite/')) { + $has_excluded = true; + } + + if (0 === strpos($entry, 'wp-content/plugins/sample-plugin/')) { + $has_included = true; + } + } + + $this->assertFalse($has_excluded, 'ultimate-multisite/* must be excluded from the export'); + $this->assertTrue($has_included, 'Other plugins must remain in the export'); + } + + /** + * Customer-reported regression: with --themes, the active theme directory + * must be present under wp-content/themes//. + */ + public function test_export_with_themes_includes_theme_files(): void { + + // Switch to the fake theme so get_template_directory() resolves to it. + add_filter('template_directory', fn () => $this->fake_themes_dir . '/sample-theme'); + add_filter('stylesheet_directory', fn () => $this->fake_themes_dir . '/sample-theme'); + + $zip_path = $this->run_export(['themes' => 1]); + $entries = $this->list_zip_entries($zip_path); + + $theme_entries = array_filter( + $entries, + static fn ($e) => 0 === strpos($e, 'wp-content/themes/') + ); + + $this->assertNotEmpty( + $theme_entries, + 'Export with --themes must include wp-content/themes/* files. ' + . 'Got entries: ' . implode(', ', $entries) + ); + } + + /** + * Customer-reported regression: with --uploads, the uploads folder must + * be present under wp-content/uploads/. + */ + public function test_export_with_uploads_includes_uploads_files(): void { + + $zip_path = $this->run_export(['uploads' => 1]); + $entries = $this->list_zip_entries($zip_path); + + $upload_entries = array_filter( + $entries, + static fn ($e) => 0 === strpos($e, 'wp-content/uploads/') + ); + + $this->assertNotEmpty( + $upload_entries, + 'Export with --uploads must include wp-content/uploads/* files. ' + . 'Got entries: ' . implode(', ', $entries) + ); + + $this->assertContains( + 'wp-content/uploads/2024/01/photo.jpg', + $entries, + 'Export must include nested upload files' + ); + + $this->assertContains( + 'wp-content/uploads/sitemap.xml', + $entries, + 'Export must include root-level upload files' + ); + } + + /** + * Combined export: plugins + themes + uploads must all land in the ZIP + * alongside the database dump. This is the "complete export" customers + * expect when ticking all three options in the export modal. + */ + public function test_export_with_all_options_includes_everything(): void { + + add_filter('template_directory', fn () => $this->fake_themes_dir . '/sample-theme'); + add_filter('stylesheet_directory', fn () => $this->fake_themes_dir . '/sample-theme'); + + $zip_path = $this->run_export( + [ + 'plugins' => 1, + 'themes' => 1, + 'uploads' => 1, + ] + ); + + $entries = $this->list_zip_entries($zip_path); + + $has_plugin = false; + $has_theme = false; + $has_upload = false; + $has_sql = false; + + foreach ($entries as $entry) { + $has_plugin = $has_plugin || (0 === strpos($entry, 'wp-content/plugins/')); + $has_theme = $has_theme || (0 === strpos($entry, 'wp-content/themes/')); + $has_upload = $has_upload || (0 === strpos($entry, 'wp-content/uploads/')); + $has_sql = $has_sql || (substr($entry, -4) === '.sql'); + } + + $this->assertTrue($has_plugin, 'Combined export must contain plugins'); + $this->assertTrue($has_theme, 'Combined export must contain themes'); + $this->assertTrue($has_upload, 'Combined export must contain uploads'); + $this->assertTrue($has_sql, 'Combined export must contain the SQL dump'); + } +}