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 `&` 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( '<', '>' ), 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<script>alert();</script>', 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<p>foo</p><span>bar</span>', 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<p>foo</p><span>bar</span>',
+ 'cleaned<p>foo</p><span>bar</span>',
+ )
+ );
}
/**
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&Q' ),
+ 'apostrophe' => array( "TEST'S", "TEST'S" ),
+ 'ampersand and apostrophe' => array( "M&M'S", "M&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>b' ),
+ 'less-than sign' => array( '10<20', '10<20' ),
+ 'ampersand before a legacy entity name' => array( '50¢', '50&cent' ),
+ 'backslash' => array( 'a\\b', 'a\\b' ),
+ 'tag-like text' => array( 'x<5>y', 'x<5>y' ),
+ 'HTML tags' => array( '<b>bold</b>', '<b>bold</b>' ),
+ );
+ }
+
+ /**
+ * 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&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&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&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&b', WC_Post_Data::decode_coupon_code_for_editing( 'a&b', $post_id ) );
+ }
+
+ /**
+ * @testdox Should store coupon codes saved through the coupon object, as the REST API does.
+ *
+ * @testWith ["B&Q", "b&q"]
+ * ["TEST'S", "test's"]
+ * ["50¢", "50&cent"]
+ * ["x<5>y", "x<5>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&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&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🎉', 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&Q', 'B&Q' ),
+ 'apostrophe' => array( "TEST'S", "test's" ),
+ 'ampersand and apostrophe' => array( "M&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&ÄRGER', 'über&ärger' ),
+ 'decoded entity' => array( '©2024', '©2024' ),
+ 'greater-than sign' => array( 'a>b', 'a>b' ),
+ 'less-than sign before a digit' => array( '10<20', '10<20' ),
+ 'less-than sign before a letter' => array( 'a<b', 'a<b' ),
+ 'ampersand before a legacy entity name' => array( '50&cent', '50¢' ),
+ 'ampersand before a semicolon' => array( 'R&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&a', 'a&a' ),
- array( "test's", "test's" ),
+ 'null' => array( null, '' ),
+ 'plain code' => array( 'DUMMYCOUPON', 'DUMMYCOUPON' ),
+ 'ampersand is stored encoded' => array( 'a&a', 'a&a' ),
+ 'encoded ampersand' => array( 'a&a', 'a&a' ),
+ 'double-encoded ampersand' => array( 'a&amp;a', 'a&a' ),
+ 'ampersand before a legacy entity name' => array( '50¢', '50&cent' ),
+ 'ampersand before a semicolon' => array( 'R&D;x', 'R&D;x' ),
+ 'non-ASCII with ampersand' => array( 'ÜBER&ÄRGER', 'ÜBER&ÄRGER' ),
+ 'apostrophe is not slashed' => array( "test's", "test's" ),
+ 'encoded apostrophe is kept as stored' => array( 'a'b', 'a'b' ),
+ 'named apostrophe is kept as stored' => array( 'a'b', 'a'b' ),
+ 'double quotes' => array( 'TEST"2024"', 'TEST"2024"' ),
+ 'encoded double quotes' => array( 'TEST"2024"', 'TEST"2024"' ),
+ 'named entity is decoded' => array( 'a©b', 'a©b' ),
+ 'non-breaking space entity is decoded' => array( 'a 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>b' ),
+ 'less-than sign before a digit' => array( '10<20', '10<20' ),
+ 'less-than sign before a letter' => array( 'a<b', 'a<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<5>y' ),
+ 'HTML tags are escaped' => array( '<b>bold</b>', '<b>bold</b>' ),
);
}
/**
- * 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', ' ', ' ', false ),
- 'thousand sep: comma entity' => array( 'woocommerce_price_thousand_sep', ',', ',', false ),
+ 'thousand sep: nbsp entity' => array( 'woocommerce_price_thousand_sep', ' ', "\u{00A0}", false ),
+ 'thousand sep: comma entity' => array( 'woocommerce_price_thousand_sep', ',', ',', 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&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🎉", "SALE🎉"]
+ * ["B&Q🎉", "B&Q🎉"]
+ *
+ * @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.
*