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
23 changes: 23 additions & 0 deletions inc/duplication/files.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ class MUCD_Files {
* @param int $to_site_id new site id.
*/
public static function copy_files($from_site_id, $to_site_id) {
/*
* Two switch_to_blog() calls are pushed onto the WordPress blog
* stack to read uploads info from the source and destination sites,
* so two restore_current_blog() calls must follow to fully unwind
* the stack. Previously only one restore was issued, leaving the
* caller's blog context pointing at the template/source site after
* copy_files() returned.
*
* The leak broke downstream code that relies on the network/admin
* context — most visibly the site_published email pipeline. When
* a customer signed up with a template the publish flow ran on the
* template's blog context, so wp_mail() picked up the template
* site's options (and per-site SMTP plugins) instead of the
* network's, causing welcome emails to silently fail. Sending a
* test email from the network admin worked because the admin
* request never went through duplication.
*
* @since 2.7.2
* @see https://github.com/Ultimate-Multisite/ultimate-multisite/issues/1163
*/

// Switch to Source site and get uploads info
switch_to_blog($from_site_id);
$wp_upload_info = wp_upload_dir();
Expand All @@ -34,6 +55,8 @@ public static function copy_files($from_site_id, $to_site_id) {
$wp_upload_info = wp_upload_dir();
$to_dir = $wp_upload_info['basedir'];

// Pop both switches: the destination, then the source.
restore_current_blog();
restore_current_blog();

$dirs = [];
Expand Down
171 changes: 171 additions & 0 deletions tests/WP_Ultimo/Helpers/Site_Duplicator_Context_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php
/**
* Regression tests for site duplication blog-context bookkeeping.
*
* Covers GH#1163: customers with site templates were not receiving
* new-site / welcome emails because MUCD_Files::copy_files() left the
* switch_to_blog() stack with the template site on top, so the post-publish
* email pipeline ran on the template's blog options and per-site SMTP
* plugins instead of the network's.
*
* @package WP_Ultimo
* @subpackage Tests
*/

namespace WP_Ultimo\Tests\Helpers;

use WP_Ultimo\Helpers\Site_Duplicator;
use WP_UnitTestCase;

/**
* Asserts that the site-duplication code path leaves the WordPress
* switch_to_blog() stack balanced and restores the caller's blog context.
*/
class Site_Duplicator_Context_Test extends WP_UnitTestCase {

/**
* Template (source) blog ID.
*
* @var int
*/
private $template_site_id;

/**
* Set up: create a template site only. No wu_create_customer() — the
* heavier setUp in Site_Duplicator_Test fails on user-id collisions in
* some local test environments and we don't need a customer here.
*/
public function setUp(): void {
parent::setUp();

if (! is_multisite()) {
$this->markTestSkipped('Site duplication tests require multisite');
}

$this->template_site_id = self::factory()->blog->create(
[
'domain' => 'context-template.example.com',
'path' => '/',
'title' => 'Context Template',
]
);
}

/**
* Clean up the template site.
*/
public function tearDown(): void {
if ($this->template_site_id) {
wpmu_delete_blog($this->template_site_id, true);
}

parent::tearDown();
}

/**
* MUCD_Files::copy_files() must keep the switch_to_blog() stack balanced —
* exactly one restore_current_blog() per switch_to_blog().
*
* The pre-fix implementation pushed two switch_to_blog() frames (source
* then target) but only popped one, leaking the source site's context
* back to the caller. This test fails on that buggy implementation.
*/
public function test_copy_files_does_not_leak_blog_context() {
$source_id = self::factory()->blog->create(
[
'domain' => 'copy-files-source.example.com',
'path' => '/',
'title' => 'Copy Files Source',
]
);

$target_id = self::factory()->blog->create(
[
'domain' => 'copy-files-target.example.com',
'path' => '/',
'title' => 'Copy Files Target',
]
);

// Defensive: unwind any switch state factory()->blog->create may leak.
while (function_exists('ms_is_switched') && ms_is_switched()) {
restore_current_blog();
}

$caller_blog_id = get_current_blog_id();
$this->assertSame(get_main_site_id(), $caller_blog_id, 'Test prerequisite: caller should be on the main site.');

// Skip the actual filesystem recursion — we're testing the
// switch_to_blog() bookkeeping at the start of copy_files(), not
// the file copy itself.
add_filter('mucd_copy_dirs', '__return_empty_array', 99);

\MUCD_Files::copy_files($source_id, $target_id);

remove_filter('mucd_copy_dirs', '__return_empty_array', 99);

// Build a diagnostic message in case of failure: shows the residual
// switch stack and current blog so it's obvious the unbalanced
// switch_to_blog() / restore_current_blog() pairing has regressed.
$switched = isset($GLOBALS['_wp_switched_stack']) ? $GLOBALS['_wp_switched_stack'] : [];
$msg = sprintf(
'After copy_files(): current=%d, stack=%s, source=%d, target=%d, caller=%d',
get_current_blog_id(),
wp_json_encode($switched),
$source_id,
$target_id,
$caller_blog_id
);

$this->assertSame(
$caller_blog_id,
get_current_blog_id(),
'copy_files() must restore the caller blog id. ' . $msg
);

$this->assertFalse(
ms_is_switched(),
'copy_files() must leave the switch_to_blog() stack empty. ' . $msg
);

wpmu_delete_blog($source_id, true);
wpmu_delete_blog($target_id, true);
}

/**
* Site_Duplicator::duplicate_site() must restore the caller's blog
* context after the full duplication pipeline runs.
*
* This is the integration-level guard against the email-send regression
* reported in GH#1163.
*/
public function test_duplicate_site_restores_blog_context() {
$caller_blog_id = get_current_blog_id();

$result = Site_Duplicator::duplicate_site(
$this->template_site_id,
'Context Restored Site',
[
'domain' => 'context-restored.example.com',
'path' => '/',
'title' => 'Context Restored Site',
]
);

$this->assertIsInt($result, 'duplicate_site() should return the new site id.');
$this->assertGreaterThan(0, $result);

$this->assertSame(
$caller_blog_id,
get_current_blog_id(),
'duplicate_site() must leave the caller on the same blog context it started on.'
);

$this->assertFalse(
ms_is_switched(),
'duplicate_site() must fully unwind the switch_to_blog() stack.'
);

wpmu_delete_blog($result, true);
}
}
Loading