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.' );
+ }
}