Commit a57ab516628 for woocommerce

commit a57ab5166282ca8cd05465828aac85ad44f29700
Author: Mike Jolley <mike.jolley@me.com>
Date:   Thu Oct 22 12:55:49 2026 +0100

    Keep coupon codes matching after the WordPress 7.2 kses rewrite (#69765)

    * Remove kses from coupon code sanitization

    * Add changelog entry for coupon code sanitization fix

    * Restore post data callback subclass compatibility

    * Sanitize coupon codes with the post title kses context

    * Update changelog entry for coupon code sanitization fix

    * Sanitize coupon codes without kses or post title filters

    * Update changelog entry for coupon code sanitization fix

    * Keep emoji encoded in the coupon code migration on utf8 tables

    * Simplify coupon code sanitization docs and null handling

diff --git a/plugins/woocommerce/changelog/fix-coupon-code-sanitization-kses b/plugins/woocommerce/changelog/fix-coupon-code-sanitization-kses
new file mode 100644
index 00000000000..764e529889f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-coupon-code-sanitization-kses
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep coupon codes with special characters working on WordPress 7.2, show `&` correctly in the coupon editor, and fix a critical error with some codes containing `<`.
diff --git a/plugins/woocommerce/includes/class-wc-post-data.php b/plugins/woocommerce/includes/class-wc-post-data.php
index 9d5e0cc0a66..22e8afc2af6 100644
--- a/plugins/woocommerce/includes/class-wc-post-data.php
+++ b/plugins/woocommerce/includes/class-wc-post-data.php
@@ -52,7 +52,9 @@ class WC_Post_Data {
 		add_action( 'edited_term', array( __CLASS__, 'edited_term' ), 10, 3 );
 		add_filter( 'update_order_item_metadata', array( __CLASS__, 'update_order_item_metadata' ), 10, 5 );
 		add_filter( 'update_post_metadata', array( __CLASS__, 'update_post_metadata' ), 10, 5 );
+		add_filter( 'wp_insert_post_data', array( __CLASS__, 'sanitize_coupon_code_on_save' ), 5, 3 );
 		add_filter( 'wp_insert_post_data', array( __CLASS__, 'wp_insert_post_data' ) );
+		add_filter( 'edit_post_title', array( __CLASS__, 'decode_coupon_code_for_editing' ), 10, 2 );
 		add_filter( 'oembed_response_data', array( __CLASS__, 'filter_oembed_response_data' ), 10, 2 );
 		add_filter( 'wp_untrash_post_status', array( __CLASS__, 'wp_untrash_post_status' ), 10, 3 );

@@ -370,14 +372,63 @@ class WC_Post_Data {
 			}
 		} elseif ( 'product' === $data['post_type'] && ProductStatus::AUTO_DRAFT === $data['post_status'] ) {
 			$data['post_title'] = 'AUTO-DRAFT';
-		} elseif ( 'shop_coupon' === $data['post_type'] ) {
-			// Coupons should never allow unfiltered HTML.
-			$data['post_title'] = wp_filter_kses( $data['post_title'] );
 		}

 		return $data;
 	}

+	/**
+	 * Sanitize coupon codes with wc_sanitize_coupon_code() when coupons are saved, so they're stored the same way for every user.
+	 *
+	 * Uses the title passed to wp_insert_post(), because WordPress has already run kses on `$data` for some users.
+	 *
+	 * @since 11.3.0
+	 * @internal
+	 *
+	 * @param array $data                An array of slashed, sanitized post data.
+	 * @param array $postarr             An array of sanitized post data.
+	 * @param array $unsanitized_postarr An array of slashed post data as passed to wp_insert_post().
+	 * @return array
+	 */
+	public static function sanitize_coupon_code_on_save( $data, $postarr, $unsanitized_postarr ) {
+		global $wpdb;
+
+		if ( ! is_array( $data ) || 'shop_coupon' !== ( $data['post_type'] ?? '' ) ) {
+			return $data;
+		}
+
+		$title = wc_sanitize_coupon_code( wp_unslash( $unsanitized_postarr['post_title'] ?? $data['post_title'] ) );
+
+		// Like wp_insert_post(), encode emoji when the column can't store them.
+		if ( in_array( $wpdb->get_col_charset( $wpdb->posts, 'post_title' ), array( 'utf8', 'utf8mb3' ), true ) ) {
+			$title = wp_encode_emoji( $title );
+		}
+
+		$data['post_title'] = wp_slash( $title );
+
+		return $data;
+	}
+
+	/**
+	 * Decode coupon codes for the title field on the coupon edit screen.
+	 *
+	 * WordPress encodes titles again for editing, so the field would otherwise show `&amp;` instead of `&`.
+	 *
+	 * @since 11.3.0
+	 * @internal
+	 *
+	 * @param string $title   Post title.
+	 * @param int    $post_id Post ID.
+	 * @return string
+	 */
+	public static function decode_coupon_code_for_editing( $title, $post_id ) {
+		if ( ! is_string( $title ) || 'shop_coupon' !== get_post_type( $post_id ) ) {
+			return $title;
+		}
+
+		return html_entity_decode( $title, ENT_QUOTES | ENT_HTML5, get_bloginfo( 'charset' ) );
+	}
+
 	/**
 	 * Change embed data for certain post types.
 	 *
diff --git a/plugins/woocommerce/includes/wc-formatting-functions.php b/plugins/woocommerce/includes/wc-formatting-functions.php
index c3032016683..387f70df552 100644
--- a/plugins/woocommerce/includes/wc-formatting-functions.php
+++ b/plugins/woocommerce/includes/wc-formatting-functions.php
@@ -377,24 +377,27 @@ function wc_format_coupon_code( $value ) {
 /**
  * Sanitize a coupon code.
  *
- * Uses sanitize_post_field since coupon codes are stored as post_titles - the sanitization and escaping must match.
- *
- * Due to the unfiltered_html capability that some (admin) users have, we need to account for slashes.
- *
- * The html_entity_decode() call handles coupon codes that contain special characters like ampersands (&), quotes ("),
- * and other HTML entities. Without this decoding step, coupon codes with special characters would fail to match
- * during application, causing legitimate coupons to be rejected.
+ * Coupon codes are plain text, but are stored as post titles with `&`, `<` and `>` encoded the same way wp_kses() did,
+ * so codes saved by earlier versions still match. Kses and the post title filters aren't used, so the result doesn't
+ * depend on the current user or on how wp_kses() parses HTML. Use the woocommerce_coupon_code filter to change codes.
  *
+ * @see WC_Formatting_Functions_Test::test_wc_sanitize_coupon_code
  * @see WC_Cart_Test::test_coupon_codes_with_special_characters
  *
  * @since  3.6.0
  * @since  10.0.0 Decode HTML entities here instead of via woocommerce_coupon_code filter.
- * @param  string $value Coupon code to format.
+ * @since  11.3.0 Encode special characters without kses or the post title filters, so the result no longer depends on the current user.
+ * @param  string|null $value Coupon code to format.
  * @return string
  */
 function wc_sanitize_coupon_code( $value ) {
-	$value = wp_kses( sanitize_post_field( 'post_title', html_entity_decode( $value ?? '', ENT_COMPAT, get_bloginfo( 'charset' ) ), 0, 'db' ), 'entities' );
-	return current_user_can( 'unfiltered_html' ) ? $value : stripslashes( $value );
+	if ( null === $value ) {
+		return '';
+	}
+
+	$value = html_entity_decode( $value, ENT_COMPAT, get_bloginfo( 'charset' ) );
+	$value = trim( wp_kses_no_null( $value, array( 'slash_zero' => 'keep' ) ) );
+	return str_replace( array( '<', '>' ), array( '&lt;', '&gt;' ), wp_kses_normalize_entities( $value ) );
 }

 /**
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 2ada38644f5..9cf6434fbe0 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -2267,10 +2267,16 @@ function wc_update_450_sanitize_coupons_code() {
 	);

 	$codes_changed = false;
+	$encode_emoji  = in_array( $wpdb->get_col_charset( $wpdb->posts, 'post_title' ), array( 'utf8', 'utf8mb3' ), true );

 	foreach ( $coupons as $key => $data ) {
 		$coupon_id = intval( $data['ID'] );
-		$code      = trim( wp_filter_kses( $data['post_title'] ) );
+		$code      = wc_sanitize_coupon_code( $data['post_title'] );
+
+		// Like coupon saves, keep emoji encoded when the column can't store them.
+		if ( $encode_emoji ) {
+			$code = wp_encode_emoji( $code );
+		}

 		if ( ! empty( $code ) && $data['post_title'] !== $code ) {
 			$wpdb->update(
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/formatting/functions.php b/plugins/woocommerce/tests/legacy/unit-tests/formatting/functions.php
index a539feabd3b..75fde2d01af 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/formatting/functions.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/formatting/functions.php
@@ -436,7 +436,7 @@ class WC_Tests_Formatting_Functions extends WC_Unit_Test_Case {
 	 * @since 3.3.0
 	 */
 	public function test_wc_format_coupon_code() {
-		$this->assertEquals( 'foo#baralert();', wc_format_coupon_code( 'foo#bar<script>alert();</script>' ) );
+		$this->assertEquals( 'foo#bar&lt;script&gt;alert();&lt;/script&gt;', wc_format_coupon_code( 'foo#bar<script>alert();</script>' ) );
 	}

 	/**
@@ -466,7 +466,14 @@ class WC_Tests_Formatting_Functions extends WC_Unit_Test_Case {
 	 * @since 2.4
 	 */
 	public function test_wc_sanitize_tooltip() {
-		$this->assertEquals( 'alert();cleaned&lt;p&gt;foo&lt;/p&gt;&lt;span&gt;bar&lt;/span&gt;', wc_sanitize_tooltip( '<script>alert();</script>cleaned<p>foo</p><span>bar</span>' ) );
+		// Since WordPress 7.2, wp_kses() also removes the contents of disallowed script elements.
+		$this->assertContains(
+			wc_sanitize_tooltip( '<script>alert();</script>cleaned<p>foo</p><span>bar</span>' ),
+			array(
+				'alert();cleaned&lt;p&gt;foo&lt;/p&gt;&lt;span&gt;bar&lt;/span&gt;',
+				'cleaned&lt;p&gt;foo&lt;/p&gt;&lt;span&gt;bar&lt;/span&gt;',
+			)
+		);
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php b/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php
index 24e522b1f56..528b605a716 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php
@@ -34,26 +34,246 @@ class WC_Post_Data_Test extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox coupon code should be always sanitized.
+	 * Data provider of coupon codes, as entered and as stored.
+	 *
+	 * @return array[]
+	 */
+	public function data_provider_coupon_codes(): array {
+		return array(
+			'ampersand'                             => array( 'B&Q', 'B&amp;Q' ),
+			'apostrophe'                            => array( "TEST'S", "TEST'S" ),
+			'ampersand and apostrophe'              => array( "M&M'S", "M&amp;M'S" ),
+			'double quotes'                         => array( 'TEST"2024"', 'TEST"2024"' ),
+			'percent followed by digits'            => array( '%20TEST', '%20TEST' ),
+			'non-ASCII'                             => array( 'CAFÉ', 'CAFÉ' ),
+			'greater-than sign'                     => array( 'a>b', 'a&gt;b' ),
+			'less-than sign'                        => array( '10<20', '10&lt;20' ),
+			'ampersand before a legacy entity name' => array( '50&cent', '50&amp;cent' ),
+			'backslash'                             => array( 'a\\b', 'a\\b' ),
+			'tag-like text'                         => array( 'x<5>y', 'x&lt;5&gt;y' ),
+			'HTML tags'                             => array( '<b>bold</b>', '&lt;b&gt;bold&lt;/b&gt;' ),
+		);
+	}
+
+	/**
+	 * Data provider for test_coupon_code_is_stored_consistently_and_found_by_customers.
+	 *
+	 * @return array[]
+	 */
+	public function data_provider_coupon_codes_by_role(): array {
+		$data = array();
+		// Shop managers lack unfiltered_html, like guests, so WordPress runs their titles through kses. Administrators don't.
+		foreach ( array( 'shop_manager', 'administrator' ) as $role ) {
+			foreach ( $this->data_provider_coupon_codes() as $label => $code ) {
+				$data[ "{$label} saved by {$role}" ] = array( $role, $code[0], $code[1] );
+			}
+		}
+
+		return $data;
+	}
+
+	/**
+	 * Create a coupon the same way as the coupon edit screen, which passes slashed $_POST data to wp_insert_post().
+	 *
+	 * @param string $code Coupon code as entered.
+	 * @return int Coupon ID.
+	 */
+	private function create_coupon_from_edit_screen( string $code ): int {
+		return wp_insert_post(
+			wp_slash(
+				array(
+					'post_title'  => $code,
+					'post_type'   => 'shop_coupon',
+					'post_status' => 'publish',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should not let post title filters change coupon codes on save or lookup.
+	 *
+	 * @testWith ["title_save_pre"]
+	 *           ["pre_post_title"]
+	 *
+	 * @param string $hook_name Post title filter.
+	 */
+	public function test_post_title_filters_do_not_change_coupon_codes( string $hook_name ): void {
+		add_filter(
+			$hook_name,
+			static function ( $title ) {
+				return $title . '-filtered';
+			}
+		);
+		$this->login_as_role( 'shop_manager' );
+
+		$coupon_id = $this->create_coupon_from_edit_screen( 'B&Q' );
+		$post_id   = self::factory()->post->create( array( 'post_title' => 'Post' ) );
+
+		$this->assertSame( 'Post-filtered', get_post( $post_id )->post_title, 'Other post types should still be filtered' );
+		$this->assertSame( 'B&amp;Q', get_post( $coupon_id )->post_title, 'Unexpected stored coupon code' );
+
+		wp_set_current_user( 0 );
+		$this->assertSame( $coupon_id, wc_get_coupon_id_by_code( wc_format_coupon_code( 'B&Q' ) ), 'The coupon should be found by the code as entered' );
+	}
+
+	/**
+	 * @testdox Should keep changes that other wp_insert_post_data filters make to coupon codes.
+	 */
+	public function test_wp_insert_post_data_filters_can_change_coupon_codes(): void {
+		add_filter(
+			'wp_insert_post_data',
+			static function ( $data ) {
+				$data['post_title'] = 'changed';
+				return $data;
+			}
+		);
+
+		$coupon_id = $this->create_coupon_from_edit_screen( 'B&Q' );
+
+		$this->assertSame( 'changed', get_post( $coupon_id )->post_title );
+	}
+
+	/**
+	 * @testdox Should display an ampersand in the coupon list without double encoding it.
+	 */
+	public function test_coupon_list_displays_ampersand(): void {
+		global $post;
+
+		require_once WC_ABSPATH . 'includes/admin/list-tables/class-wc-admin-list-table-coupons.php';
+		$this->login_as_administrator();
+		$coupon_id = $this->create_coupon_from_edit_screen( 'Coupon&Test' );
+		$post      = get_post( $coupon_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The list table reads the current row from the global post.
+		$sut       = new WC_Admin_List_Table_Coupons();
+
+		$this->expectOutputRegex( '/>coupon&amp;test<\/a>/' );
+		$sut->render_columns( 'coupon_code', $coupon_id );
+	}
+
+	/**
+	 * @testdox Should store coupon codes in the same format for every role, and find them by the code customers enter.
+	 *
+	 * @dataProvider data_provider_coupon_codes_by_role
+	 *
+	 * @param string $role           Role saving the coupon.
+	 * @param string $code           Code as entered by the merchant and by the customer.
+	 * @param string $expected_title Expected post_title in the database.
 	 */
-	public function test_coupon_code_sanitization() {
+	public function test_coupon_code_is_stored_consistently_and_found_by_customers( string $role, string $code, string $expected_title ): void {
+		$this->login_as_role( $role );
+
+		$coupon_id = $this->create_coupon_from_edit_screen( $code );
+
+		$this->assertSame( $expected_title, get_post( $coupon_id )->post_title, 'Unexpected stored coupon code' );
+
+		wp_set_current_user( 0 );
+		$this->assertSame( $coupon_id, wc_get_coupon_id_by_code( wc_format_coupon_code( $code ) ), 'The coupon should be found by the code as entered' );
+	}
+
+	/**
+	 * @testdox Should keep the stored coupon code when another user updates the coupon.
+	 *
+	 * @dataProvider data_provider_coupon_codes
+	 *
+	 * @param string $code           Code as entered by the merchant.
+	 * @param string $expected_title Expected post_title in the database.
+	 */
+	public function test_coupon_code_is_unchanged_when_the_coupon_is_updated( string $code, string $expected_title ): void {
+		$this->login_as_administrator();
+		$coupon_id = $this->create_coupon_from_edit_screen( $code );
+
+		// wp_update_post() slashes the stored post before saving it again, and shop managers' titles go through kses.
 		$this->login_as_role( 'shop_manager' );
-		$coupon    = WC_Helper_Coupon::create_coupon( 'a&a' );
-		$post_data = get_post( $coupon->get_id() );
-		$this->assertEquals( 'a&amp;a', $post_data->post_title );
-		$coupon->delete( true );
+		wp_update_post(
+			array(
+				'ID'          => $coupon_id,
+				'post_status' => 'draft',
+			)
+		);
+
+		$this->assertSame( $expected_title, get_post( $coupon_id )->post_title );
+	}
+
+	/**
+	 * @testdox Should show coupon codes as entered on the edit screen, and keep the stored code when saved again.
+	 *
+	 * @dataProvider data_provider_coupon_codes
+	 *
+	 * @param string $code           Code as entered by the merchant.
+	 * @param string $expected_title Expected post_title in the database.
+	 */
+	public function test_coupon_code_is_shown_as_entered_on_the_edit_screen( string $code, string $expected_title ): void {
+		$this->login_as_administrator();
+		$coupon_id = $this->create_coupon_from_edit_screen( $code );

+		// The edit screen prints this value in the title field, and the browser decodes it once.
+		$shown_code = html_entity_decode( get_post( $coupon_id, OBJECT, 'edit' )->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+
+		$this->assertSame( $code, $shown_code, 'The edit screen should show the code as entered' );
+
+		wp_update_post(
+			wp_slash(
+				array(
+					'ID'         => $coupon_id,
+					'post_title' => $shown_code,
+				)
+			)
+		);
+
+		$this->assertSame( $expected_title, get_post( $coupon_id )->post_title, 'Saving the shown code should not change the stored code' );
+	}
+
+	/**
+	 * @testdox Should leave the edit screen title of other post types unchanged.
+	 */
+	public function test_edit_post_title_is_unchanged_for_other_post_types(): void {
+		$post_id = self::factory()->post->create();
+
+		$this->assertSame( 'a&amp;b', WC_Post_Data::decode_coupon_code_for_editing( 'a&amp;b', $post_id ) );
+	}
+
+	/**
+	 * @testdox Should store coupon codes saved through the coupon object, as the REST API does.
+	 *
+	 * @testWith ["B&Q", "b&amp;q"]
+	 *           ["TEST'S", "test's"]
+	 *           ["50&cent", "50&amp;cent"]
+	 *           ["x<5>y", "x&lt;5&gt;y"]
+	 *
+	 * @param string $code           Code as entered.
+	 * @param string $expected_title Expected post_title in the database.
+	 */
+	public function test_coupon_code_saved_with_crud_is_stored_consistently( string $code, string $expected_title ): void {
 		$this->login_as_administrator();
-		$coupon    = WC_Helper_Coupon::create_coupon( 'b&b' );
-		$post_data = get_post( $coupon->get_id() );
-		$this->assertEquals( 'b&amp;b', $post_data->post_title );
-		$coupon->delete( true );
+
+		$coupon = new WC_Coupon();
+		$coupon->set_code( $code );
+		$coupon->save();
+
+		$this->assertSame( $expected_title, get_post( $coupon->get_id() )->post_title, 'Unexpected stored coupon code' );

 		wp_set_current_user( 0 );
-		$coupon    = WC_Helper_Coupon::create_coupon( 'c&c' );
-		$post_data = get_post( $coupon->get_id() );
-		$this->assertEquals( 'c&amp;c', $post_data->post_title );
-		$coupon->delete( true );
+		$this->assertSame( $coupon->get_id(), wc_get_coupon_id_by_code( wc_format_coupon_code( $code ) ), 'The coupon should be found by the code as entered' );
+	}
+
+	/**
+	 * @testdox Should encode emoji in coupon codes when the posts table can't store them, like WordPress does.
+	 */
+	public function test_coupon_code_emoji_is_encoded_for_utf8_tables(): void {
+		global $wpdb;
+
+		add_filter(
+			'pre_get_col_charset',
+			function ( $charset, $table, $column ) use ( $wpdb ) {
+				return $wpdb->posts === $table && 'post_title' === $column ? 'utf8' : $charset;
+			},
+			10,
+			3
+		);
+
+		$coupon_id = $this->create_coupon_from_edit_screen( 'SALE🎉' );
+
+		$this->assertSame( 'SALE&#x1f389;', get_post( $coupon_id )->post_title );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/wc-coupon-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-coupon-functions-test.php
new file mode 100644
index 00000000000..9136c10a8d1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/wc-coupon-functions-test.php
@@ -0,0 +1,67 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Coupon functions tests
+ *
+ * @package WooCommerce\Tests\Coupon.
+ */
+
+/**
+ * Class WC_Coupon_Functions_Test
+ */
+class WC_Coupon_Functions_Test extends \WC_Unit_Test_Case {
+
+	/**
+	 * Data provider for test_existing_coupon_is_found_by_the_code_shown_to_merchants.
+	 *
+	 * Stored titles are in the formats earlier versions saved, which used kses to encode them.
+	 *
+	 * @return array[]
+	 */
+	public function data_provider_existing_coupon_codes(): array {
+		return array(
+			'plain code'                            => array( 'SUMMER10', 'summer10' ),
+			'ampersand'                             => array( 'B&amp;Q', 'B&Q' ),
+			'apostrophe'                            => array( "TEST'S", "test's" ),
+			'ampersand and apostrophe'              => array( "M&amp;M'S", "M&M'S" ),
+			'double quotes'                         => array( 'TEST"2024"', 'TEST"2024"' ),
+			'percent followed by digits'            => array( '%20TEST', '%20TEST' ),
+			'percent sign'                          => array( '50%OFF', '50%off' ),
+			'non-ASCII'                             => array( 'CAFÉ', 'café' ),
+			'non-ASCII with ampersand'              => array( 'ÜBER&amp;ÄRGER', 'über&ärger' ),
+			'decoded entity'                        => array( '©2024', '©2024' ),
+			'greater-than sign'                     => array( 'a&gt;b', 'a>b' ),
+			'less-than sign before a digit'         => array( '10&lt;20', '10<20' ),
+			'less-than sign before a letter'        => array( 'a&lt;b', 'a<b' ),
+			'ampersand before a legacy entity name' => array( '50&amp;cent', '50&cent' ),
+			'ampersand before a semicolon'          => array( 'R&amp;D;x', 'R&D;x' ),
+			'backslash saved by an administrator'   => array( 'a\\b', 'a\\b' ),
+		);
+	}
+
+	/**
+	 * @testdox Should find coupons saved by earlier versions by the code shown to merchants.
+	 *
+	 * @dataProvider data_provider_existing_coupon_codes
+	 *
+	 * @param string $stored_title Post title as saved by an earlier version.
+	 * @param string $shown_code   Code as shown to the merchant, and so as entered by customers.
+	 */
+	public function test_existing_coupon_is_found_by_the_code_shown_to_merchants( string $stored_title, string $shown_code ): void {
+		global $wpdb;
+
+		$coupon_id = wp_insert_post(
+			array(
+				'post_title'  => 'placeholder',
+				'post_type'   => 'shop_coupon',
+				'post_status' => 'publish',
+			)
+		);
+		$wpdb->update( $wpdb->posts, array( 'post_title' => $stored_title ), array( 'ID' => $coupon_id ) );
+		clean_post_cache( $coupon_id );
+
+		wp_set_current_user( 0 );
+		$this->assertSame( $coupon_id, wc_get_coupon_id_by_code( wc_format_coupon_code( $shown_code ) ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php
index 6f0f71b1d5b..ae35228e8ba 100644
--- a/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-formatting-functions-test.php
@@ -12,6 +12,26 @@ declare( strict_types = 1 );
  */
 class WC_Formatting_Functions_Test extends \WC_Unit_Test_Case {

+	/**
+	 * User IDs to sanitize coupon codes as, keyed by role, with 0 for a guest.
+	 *
+	 * @var int[]
+	 */
+	private static $user_ids_by_role = array();
+
+	/**
+	 * Create the users shared by the tests in this class.
+	 *
+	 * @param WP_UnitTest_Factory $factory Test data factory.
+	 */
+	public static function wpSetUpBeforeClass( $factory ): void {
+		self::$user_ids_by_role = array(
+			'guest'         => 0,
+			'shop_manager'  => $factory->user->create( array( 'role' => 'shop_manager' ) ),
+			'administrator' => $factory->user->create( array( 'role' => 'administrator' ) ),
+		);
+	}
+
 	/**
 	 * Starts each test with an empty WC_Admin_Settings errors array.
 	 */
@@ -38,22 +58,51 @@ class WC_Formatting_Functions_Test extends \WC_Unit_Test_Case {
 	 */
 	public function data_provider_test_wc_sanitize_coupon_code(): array {
 		return array(
-			array( 'DUMMYCOUPON', 'DUMMYCOUPON' ),
-			array( 'a&amp;a', 'a&a' ),
-			array( "test's", "test's" ),
+			'null'                                  => array( null, '' ),
+			'plain code'                            => array( 'DUMMYCOUPON', 'DUMMYCOUPON' ),
+			'ampersand is stored encoded'           => array( 'a&a', 'a&amp;a' ),
+			'encoded ampersand'                     => array( 'a&amp;a', 'a&amp;a' ),
+			'double-encoded ampersand'              => array( 'a&amp;amp;a', 'a&amp;a' ),
+			'ampersand before a legacy entity name' => array( '50&cent', '50&amp;cent' ),
+			'ampersand before a semicolon'          => array( 'R&D;x', 'R&amp;D;x' ),
+			'non-ASCII with ampersand'              => array( 'ÜBER&ÄRGER', 'ÜBER&amp;ÄRGER' ),
+			'apostrophe is not slashed'             => array( "test's", "test's" ),
+			'encoded apostrophe is kept as stored'  => array( 'a&#039;b', 'a&#039;b' ),
+			'named apostrophe is kept as stored'    => array( 'a&apos;b', 'a&apos;b' ),
+			'double quotes'                         => array( 'TEST"2024"', 'TEST"2024"' ),
+			'encoded double quotes'                 => array( 'TEST&quot;2024&quot;', 'TEST"2024"' ),
+			'named entity is decoded'               => array( 'a&copy;b', 'a©b' ),
+			'non-breaking space entity is decoded'  => array( 'a&nbsp;b', "a\u{00A0}b" ),
+			'percent followed by digits is kept'    => array( '%20test', '%20test' ),
+			'percent sign'                          => array( '50%off', '50%off' ),
+			'greater-than sign'                     => array( 'a>b', 'a&gt;b' ),
+			'less-than sign before a digit'         => array( '10<20', '10&lt;20' ),
+			'less-than sign before a letter'        => array( 'a<b', 'a&lt;b' ),
+			'backslash is kept'                     => array( 'a\\b', 'a\\b' ),
+			'surrounding whitespace is trimmed'     => array( '  padded  ', 'padded' ),
+			'control characters are removed'        => array( "a\0b\x07c", 'abc' ),
+			'tag-like text with a numeric name'     => array( 'x<5>y', 'x&lt;5&gt;y' ),
+			'HTML tags are escaped'                 => array( '<b>bold</b>', '&lt;b&gt;bold&lt;/b&gt;' ),
 		);
 	}

 	/**
-	 * Test wc_sanitize_coupon_code() function.
+	 * @testdox Should sanitize coupon codes the same way for guests, shop managers and administrators.
 	 *
 	 * @dataProvider data_provider_test_wc_sanitize_coupon_code
 	 *
-	 * @param string $assert Expected result.
-	 * @param string $input Input for wc_sanitize_coupon_code().
+	 * @param string|null $input    Input for wc_sanitize_coupon_code().
+	 * @param string      $expected Expected result.
 	 */
-	public function test_wc_sanitize_coupon_code( string $assert, string $input ) {
-		$this->assertSame( $assert, wc_sanitize_coupon_code( $input ) );
+	public function test_wc_sanitize_coupon_code( ?string $input, string $expected ): void {
+		foreach ( self::$user_ids_by_role as $role => $user_id ) {
+			wp_set_current_user( $user_id );
+
+			$sanitized = wc_sanitize_coupon_code( $input );
+
+			$this->assertSame( $expected, $sanitized, "Unexpected result for {$role}" );
+			$this->assertSame( $sanitized, wc_sanitize_coupon_code( $sanitized ), "Sanitizing again should not change the code for {$role}" );
+		}
 	}

 	/**
@@ -121,8 +170,8 @@ class WC_Formatting_Functions_Test extends \WC_Unit_Test_Case {
 			'thousand sep: space'           => array( 'woocommerce_price_thousand_sep', ' ', ' ', false ),
 			'thousand sep: two spaces'      => array( 'woocommerce_price_thousand_sep', '  ', '  ', false ),
 			'thousand sep: empty'           => array( 'woocommerce_price_thousand_sep', '', '', false ),
-			'thousand sep: nbsp entity'     => array( 'woocommerce_price_thousand_sep', '&nbsp;', '&nbsp;', false ),
-			'thousand sep: comma entity'    => array( 'woocommerce_price_thousand_sep', '&#44;', '&#044;', false ),
+			'thousand sep: nbsp entity'     => array( 'woocommerce_price_thousand_sep', '&nbsp;', "\u{00A0}", false ),
+			'thousand sep: comma entity'    => array( 'woocommerce_price_thousand_sep', '&#44;', ',', false ),
 			'thousand sep: not submitted'   => array( 'woocommerce_price_thousand_sep', null, '|', false ),
 			'thousand sep: single digit'    => array( 'woocommerce_price_thousand_sep', '1', '|', true ),
 			'thousand sep: digit+symbol'    => array( 'woocommerce_price_thousand_sep', '1,', '|', true ),
@@ -150,7 +199,8 @@ class WC_Formatting_Functions_Test extends \WC_Unit_Test_Case {

 		$result = wc_format_option_price_separators( $raw_value, array( 'id' => $option_id ), $raw_value );

-		$this->assertSame( $expected, $result );
+		// Compare the separator as displayed, since WordPress 7.2's wp_kses() stores character references as the characters themselves.
+		$this->assertSame( $expected, html_entity_decode( $result, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );

 		$errors = $this->get_wc_admin_settings_errors();

diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index 6e54eeba09a..01dc783f8fd 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -476,19 +476,7 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
 	 * @testdox Migration sanitizes dirty coupon codes and invalidates every coupon code lookup entry once, on the last batch.
 	 */
 	public function test_wc_update_450_sanitize_coupons_code_invalidates_the_lookup_cache(): void {
-		global $wpdb;
-
-		$coupon_id = wp_insert_post(
-			array(
-				'post_type'   => 'shop_coupon',
-				'post_title'  => 'dirty-code',
-				'post_status' => 'publish',
-			)
-		);
-
-		// The migration exists for titles WordPress would not store today, so write the raw one directly.
-		$wpdb->update( $wpdb->posts, array( 'post_title' => ' dirty-code ' ), array( 'ID' => $coupon_id ) );
-		clean_post_cache( $coupon_id );
+		$coupon_id = $this->create_coupon_with_stored_title( ' dirty-code ' );

 		// Start the batch at this coupon, so the assertions do not depend on what else is in the database.
 		update_option( 'woocommerce_update_450_last_coupon_id', $coupon_id - 1 );
@@ -544,6 +532,81 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Migration stores coupon codes in the format coupon lookups expect, without adding slashes.
+	 *
+	 * @testWith ["a&b", "a&amp;b"]
+	 *           ["test's", "test's"]
+	 *           ["a\\b", "a\\b"]
+	 *
+	 * @param string $stored_title   Title stored before the migration.
+	 * @param string $expected_title Title expected after the migration.
+	 */
+	public function test_wc_update_450_sanitize_coupons_code_stores_codes_as_lookups_expect( string $stored_title, string $expected_title ): void {
+		$coupon_id = $this->create_coupon_with_stored_title( $stored_title );
+
+		update_option( 'woocommerce_update_450_last_coupon_id', $coupon_id - 1 );
+
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$this->run_wc_update_450_sanitize_coupons_code_to_completion();
+
+		$this->assertSame( $expected_title, get_post( $coupon_id )->post_title );
+	}
+
+	/**
+	 * @testdox Migration keeps emoji in coupon codes encoded when the posts table can't store them, like coupon saves do.
+	 *
+	 * @testWith ["SALE&#x1f389;", "SALE&#x1f389;"]
+	 *           ["B&Q&#x1f389;", "B&amp;Q&#x1f389;"]
+	 *
+	 * @param string $stored_title   Title stored before the migration.
+	 * @param string $expected_title Title expected after the migration.
+	 */
+	public function test_wc_update_450_sanitize_coupons_code_keeps_emoji_encoded_for_utf8_tables( string $stored_title, string $expected_title ): void {
+		global $wpdb;
+
+		$coupon_id = $this->create_coupon_with_stored_title( $stored_title );
+
+		add_filter(
+			'pre_get_col_charset',
+			function ( $charset, $table, $column ) use ( $wpdb ) {
+				return $wpdb->posts === $table && 'post_title' === $column ? 'utf8' : $charset;
+			},
+			10,
+			3
+		);
+		update_option( 'woocommerce_update_450_last_coupon_id', $coupon_id - 1 );
+
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$this->run_wc_update_450_sanitize_coupons_code_to_completion();
+
+		$this->assertSame( $expected_title, get_post( $coupon_id )->post_title );
+	}
+
+	/**
+	 * Create a coupon and write its title directly, as the 4.5.0 migration exists for titles WordPress would not store today.
+	 *
+	 * @param string $title Raw post title to store.
+	 * @return int Coupon ID.
+	 */
+	private function create_coupon_with_stored_title( string $title ): int {
+		global $wpdb;
+
+		$coupon_id = wp_insert_post(
+			array(
+				'post_type'   => 'shop_coupon',
+				'post_title'  => 'placeholder',
+				'post_status' => 'publish',
+			)
+		);
+		$wpdb->update( $wpdb->posts, array( 'post_title' => $title ), array( 'ID' => $coupon_id ) );
+		clean_post_cache( $coupon_id );
+
+		return $coupon_id;
+	}
+
 	/**
 	 * Run the 4.5.0 coupon code migration until it reports there is nothing left to process.
 	 *