Commit 161eb486ab7 for woocommerce

commit 161eb486ab78c281832a78c1939bf20539851ef2
Author: Yuliyan Slavchev <yuliyan.slavchev@gmail.com>
Date:   Wed Jun 3 13:54:43 2026 +0300

    Prevent email preview dummy order from being persisted (#65407)

    * Prevent email preview dummy order from being persisted

    * Harden PreviewOrder against touching real orders in the database

diff --git a/plugins/woocommerce/changelog/stomail-8111-prevent-preview-overwriting-real-order b/plugins/woocommerce/changelog/stomail-8111-prevent-preview-overwriting-real-order
new file mode 100644
index 00000000000..42b8a899334
--- /dev/null
+++ b/plugins/woocommerce/changelog/stomail-8111-prevent-preview-overwriting-real-order
@@ -0,0 +1,3 @@
+Significance: patch
+Type: fix
+Comment: Prevent the email preview from overwriting or reading from an existing order whose id collides with the preview dummy order id.
diff --git a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
index 9b7f917ad23..552b4931928 100644
--- a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
+++ b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
@@ -372,8 +372,9 @@ class EmailPreview {
 		$variation            = $this->get_dummy_product_variation();
 		$downloadable_product = $this->get_dummy_downloadable_product();

-		$order = new WC_Order();
-		$order->set_id( 12345 );
+		// PreviewOrder keeps its id at 0 so it can never read from or write to a
+		// real order. It surfaces a display number via get_order_number() instead.
+		$order = new PreviewOrder();

 		// Create and add product items manually without saving to database.
 		// Use add_item() instead of add_product() to avoid immediate database writes.
diff --git a/plugins/woocommerce/src/Internal/Admin/EmailPreview/PreviewOrder.php b/plugins/woocommerce/src/Internal/Admin/EmailPreview/PreviewOrder.php
new file mode 100644
index 00000000000..1dfc9bfc88e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/EmailPreview/PreviewOrder.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * Non-persistent WC_Order used for email preview.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Admin\EmailPreview;
+
+use WC_Order;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * WC_Order subclass for the email preview.
+ *
+ * The preview needs an order to render, but it must never read from or write
+ * to a real order in the database. The key to that is the id: order methods
+ * that touch the database key off get_id() and resolve to nothing when it is
+ * 0 (add_order_note(), remove_order_items(), get_refunds(), the refund totals,
+ * item/meta reads, etc.). So this order keeps id 0 and exposes the preview's
+ * display number through get_order_number() instead.
+ *
+ * Two cases aren't safe at id 0 and are handled explicitly:
+ * - save() and its siblings would insert a new row, so they are no-ops.
+ * - get_customer_order_notes() passes the id to get_comments(), which treats
+ *   0 as "no filter" and would return every order note on the site, so it is
+ *   overridden to return nothing.
+ *
+ * The item/meta caches are pre-filled as empty too, as a guard against any
+ * future read path that doesn't check the id first.
+ */
+class PreviewOrder extends WC_Order {
+
+	/**
+	 * The order number shown in the preview. Not a real order id, so it can't
+	 * collide with a row in the database.
+	 */
+	const PREVIEW_ORDER_NUMBER = '12345';
+
+	/**
+	 * Constructor.
+	 *
+	 * @param int|object|WC_Order $order Order to read. Defaults to 0 (a new, empty order).
+	 */
+	public function __construct( $order = 0 ) {
+		parent::__construct( $order );
+
+		foreach ( $this->item_types_to_group as $group ) {
+			$this->items[ $group ] = array();
+		}
+		$this->meta_data = array();
+	}
+
+	/**
+	 * Get the order number to display.
+	 *
+	 * The real id stays 0, so this provides a representative number for the
+	 * preview without tying the order to a database row.
+	 *
+	 * @return string
+	 */
+	public function get_order_number() {
+		return self::PREVIEW_ORDER_NUMBER;
+	}
+
+	/**
+	 * Block save(). A preview order should never be written to the database.
+	 *
+	 * @return int The order id (unchanged).
+	 */
+	public function save() {
+		wc_get_logger()->warning(
+			'Email preview order save() blocked to prevent writing to the database.',
+			array( 'source' => 'email-preview' )
+		);
+		return $this->get_id();
+	}
+
+	/**
+	 * Block save_meta_data(). Extensions sometimes call update_meta_data()
+	 * followed by save_meta_data() directly, bypassing save().
+	 */
+	public function save_meta_data(): void {
+		// Intentionally empty.
+	}
+
+	/**
+	 * Block delete(). A preview order has no row to delete.
+	 *
+	 * @param bool $force_delete Should the order be deleted permanently.
+	 * @return bool Always false.
+	 */
+	public function delete( $force_delete = false ) {
+		return false;
+	}
+
+	/**
+	 * A preview order has no customer notes. The parent passes the id straight
+	 * to get_comments(), which treats id 0 as "return every order note".
+	 *
+	 * @return array
+	 */
+	public function get_customer_order_notes() {
+		return array();
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/EmailPreview/EmailPreviewTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/EmailPreview/EmailPreviewTest.php
index 94d9e226001..5ff543d7ea8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/EmailPreview/EmailPreviewTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/EmailPreview/EmailPreviewTest.php
@@ -4,7 +4,9 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\Admin\EmailPreview;

 use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
+use Automattic\WooCommerce\Internal\Admin\EmailPreview\PreviewOrder;
 use WC_Emails;
+use WC_Helper_Order;
 use WC_Product;
 use WC_Unit_Test_Case;

@@ -331,4 +333,110 @@ class EmailPreviewTest extends WC_Unit_Test_Case {
 		delete_transient( $heading_key );
 		delete_transient( $additional_key );
 	}
+
+	/**
+	 * @testdox Calling save() on the preview dummy order must not overwrite a real order whose id matches the dummy's.
+	 */
+	public function test_dummy_order_save_does_not_overwrite_real_order(): void {
+		$real_order = WC_Helper_Order::create_order();
+		$real_id    = $real_order->get_id();
+		$real_order->set_billing_first_name( 'Real' );
+		$real_order->set_billing_last_name( 'Customer' );
+		$real_order->set_total( 999 );
+		$real_order->save();
+
+		$snapshot = array(
+			'first_name' => $real_order->get_billing_first_name(),
+			'last_name'  => $real_order->get_billing_last_name(),
+			'total'      => $real_order->get_total(),
+			'status'     => $real_order->get_status(),
+		);
+
+		$listener = function ( $order ) use ( $real_id ) {
+			$order->set_id( $real_id );
+			$order->set_billing_first_name( 'Pwned' );
+			$order->set_billing_last_name( 'Pwned' );
+			$order->set_total( 1 );
+			$order->save();
+			return $order;
+		};
+		add_filter( 'woocommerce_email_preview_dummy_order', $listener );
+
+		$this->sut->render();
+
+		remove_filter( 'woocommerce_email_preview_dummy_order', $listener );
+
+		$reloaded = wc_get_order( $real_id );
+		$this->assertSame( $snapshot['first_name'], $reloaded->get_billing_first_name(), 'Billing first name must be unchanged.' );
+		$this->assertSame( $snapshot['last_name'], $reloaded->get_billing_last_name(), 'Billing last name must be unchanged.' );
+		$this->assertSame( $snapshot['total'], $reloaded->get_total(), 'Order total must be unchanged.' );
+		$this->assertSame( $snapshot['status'], $reloaded->get_status(), 'Order status must be unchanged.' );
+	}
+
+	/**
+	 * @testdox Reading meta on the preview dummy order must not leak real order meta from the database.
+	 */
+	public function test_dummy_order_does_not_leak_real_order_meta(): void {
+		$real_order = WC_Helper_Order::create_order();
+		$real_id    = $real_order->get_id();
+		$real_order->update_meta_data( '_secret_value', 'do-not-leak' );
+		$real_order->save();
+
+		$captured = 'sentinel';
+		$listener = function ( $order ) use ( $real_id, &$captured ) {
+			$order->set_id( $real_id );
+			$captured = $order->get_meta( '_secret_value' );
+			return $order;
+		};
+		add_filter( 'woocommerce_email_preview_dummy_order', $listener );
+
+		$content = $this->sut->render();
+
+		remove_filter( 'woocommerce_email_preview_dummy_order', $listener );
+
+		$this->assertSame( '', $captured, 'Preview dummy must not lazy-load meta from the orders table.' );
+		$this->assertStringNotContainsString( 'do-not-leak', $content, 'Real order meta must not appear in the rendered preview.' );
+	}
+
+	/**
+	 * @testdox PreviewOrder save() is a no-op and does not insert a row when id is unset.
+	 */
+	public function test_preview_order_save_is_noop(): void {
+		$order = new PreviewOrder();
+		$order->set_billing_first_name( 'Phantom' );
+		$order->save();
+
+		$this->assertSame( 0, $order->get_id(), 'PreviewOrder must not be assigned a real database id on save().' );
+	}
+
+	/**
+	 * @testdox PreviewOrder keeps its id at 0 but still shows a representative order number.
+	 */
+	public function test_preview_order_displays_number_without_a_real_id(): void {
+		$order = new PreviewOrder();
+
+		$this->assertSame( 0, $order->get_id(), 'PreviewOrder must not carry a real order id.' );
+		$this->assertSame( '12345', $order->get_order_number(), 'PreviewOrder must expose a display order number.' );
+	}
+
+	/**
+	 * @testdox PreviewOrder database methods that key off the order id are inert.
+	 */
+	public function test_preview_order_database_methods_are_inert(): void {
+		$real_order = WC_Helper_Order::create_order();
+		$real_order->add_order_note( 'Customer note', 1 );
+		$real_order->save();
+		wc_create_refund(
+			array(
+				'order_id' => $real_order->get_id(),
+				'amount'   => 5,
+			)
+		);
+
+		$order = new PreviewOrder();
+
+		$this->assertSame( 0, $order->add_order_note( 'Preview note' ), 'add_order_note() must not write a note.' );
+		$this->assertSame( array(), $order->get_refunds(), 'get_refunds() must not read refunds from the database.' );
+		$this->assertSame( array(), $order->get_customer_order_notes(), 'get_customer_order_notes() must not read notes from the database.' );
+	}
 }