Commit 0c1f86f7d7e for woocommerce

commit 0c1f86f7d7e2b1eb273d54d4aebd953bbef27136
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Wed Jul 15 17:53:43 2026 +0300

    Fix account saves with unchanged email display names (#66645)

    * fix: allow unchanged email-like display names

    Context: My Account account details pre-fills the display name from the stored user profile. Some customers can already have an email-like stored display name when stores allow email-as-username registration.

    Problem: save_account_details() rejected every submitted email-like display name, even when it matched the stored value. That blocked unrelated account-field updates before WooCommerce could save or normalize the customer data.

    Solution: compare the submitted display name with the current stored display name before adding the privacy notice. New email-like display-name changes still fail, while unchanged legacy values can pass through the existing save flow and customer sync.

    Refs #32001

    * test(account): Harden display name regression coverage

    The account-details regression could exit PHPUnit successfully before its rejection assertions, accepted any success redirect, and left a test-created session behind.

    Intercept redirects for every scenario, assert the exact Account details destination and complete non-persistence, restore session ownership, and remove the role-only fixture wrapper.

    Refs #32001

    * refactor(account): Preserve account handler failure behavior

    Reading the stored display name introduced a defensive early return for an invalid user lookup. That return was unrelated to the issue and silently bypassed the handler's established validation-hook path.

    Restore the existing control flow and document the authenticated-user invariant for static analysis, leaving only the unchanged-display-name compatibility condition as runtime behavior.

    Refs #32001

    * chore: Correct account fix changelog significance

    The account-details change is a backward-compatible correction to existing validation behavior, but its changelog entry classified it as a minor release.

    Mark the fix as patch significance to match WooCommerce changelog semantics.

    Refs #32001

    * refactor(account): Simplify current display name lookup

    The local type annotation used to preserve the handler's existing control flow satisfies PHPStan but is rejected by branch-level PHPCS in this legacy file.

    Use the prior-art false fallback only for the newly added display-name read. Existing user-property dereferences remain unchanged, while both static gates can verify the focused fix.

    Refs #32001

    * fix(account): Normalize stored display names before comparison

    Account detail submissions clean the incoming display name before validation, while the stored value was compared without equivalent normalization.

    This could reject an otherwise unchanged email-like display name when a legacy or imported user record bypassed WordPress user-field sanitization.

    Clean the stored display name before comparing it and cover the bypass case with a handler-level regression test. Normal WordPress-managed records retain the same behavior.

    Refs #32001

    * refactor(account): Identify display name validation notices

    The display-name privacy validation exposed only translated text, so handler tests had to identify the error by its English message.

    Attach the existing account_display_name field identifier to the notice, matching adjacent account validation errors. Tests can now verify the affected field and observable error state without coupling to user-facing copy.

    The validation condition, message, and persistence behavior remain unchanged.

    Refs #32001

    * refactor(account): Document current user type invariant

    The account handler obtains a positive current user ID before loading the user, but static analysis still treats get_user_by() as WP_User|false.

    That mismatch previously left several property accesses in the PHPStan baseline and encouraged a one-off conditional around display_name even though earlier accesses already rely on the same invariant.

    Document the established WP_User type at the lookup and remove the resolved property errors from the baseline. Runtime behavior is unchanged.

    Refs #32001

diff --git a/plugins/woocommerce/changelog/fix-32001-display-name-email b/plugins/woocommerce/changelog/fix-32001-display-name-email
new file mode 100644
index 00000000000..89c37db5a94
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-32001-display-name-email
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Allow customers to save account details when an unchanged display name is already an email address.
diff --git a/plugins/woocommerce/includes/class-wc-form-handler.php b/plugins/woocommerce/includes/class-wc-form-handler.php
index f6bfb58b2b7..cc8f3be7785 100644
--- a/plugins/woocommerce/includes/class-wc-form-handler.php
+++ b/plugins/woocommerce/includes/class-wc-form-handler.php
@@ -329,10 +329,12 @@ class WC_Form_Handler {
 		$save_pass            = true;

 		// Current user data.
-		$current_user       = get_user_by( 'id', $user_id );
-		$current_first_name = $current_user->first_name;
-		$current_last_name  = $current_user->last_name;
-		$current_email      = $current_user->user_email;
+		/** @var WP_User $current_user */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- Type hint for PHPStan.
+		$current_user         = get_user_by( 'id', $user_id );
+		$current_first_name   = $current_user->first_name;
+		$current_last_name    = $current_user->last_name;
+		$current_email        = $current_user->user_email;
+		$current_display_name = wc_clean( $current_user->display_name );

 		// New user data.
 		$user               = new stdClass();
@@ -341,9 +343,9 @@ class WC_Form_Handler {
 		$user->last_name    = $account_last_name;
 		$user->display_name = $account_display_name;

-		// Prevent display name to be changed to email.
-		if ( is_email( $account_display_name ) ) {
-			wc_add_notice( __( 'Display name cannot be changed to email address due to privacy concern.', 'woocommerce' ), 'error' );
+		// Prevent display name from being changed to an email address.
+		if ( is_email( $account_display_name ) && $account_display_name !== $current_display_name ) {
+			wc_add_notice( __( 'Display name cannot be changed to email address due to privacy concern.', 'woocommerce' ), 'error', array( 'id' => 'account_display_name' ) );
 		}

 		// Handle required fields.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 4ea2973c9c5..e15740079ea 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -11664,36 +11664,6 @@ parameters:
 			count: 2
 			path: includes/class-wc-form-handler.php

-		-
-			message: '#^Cannot access property \$ID on WP_User\|false\.$#'
-			identifier: property.nonObject
-			count: 1
-			path: includes/class-wc-form-handler.php
-
-		-
-			message: '#^Cannot access property \$first_name on WP_User\|false\.$#'
-			identifier: property.nonObject
-			count: 1
-			path: includes/class-wc-form-handler.php
-
-		-
-			message: '#^Cannot access property \$last_name on WP_User\|false\.$#'
-			identifier: property.nonObject
-			count: 1
-			path: includes/class-wc-form-handler.php
-
-		-
-			message: '#^Cannot access property \$user_email on WP_User\|false\.$#'
-			identifier: property.nonObject
-			count: 2
-			path: includes/class-wc-form-handler.php
-
-		-
-			message: '#^Cannot access property \$user_pass on WP_User\|false\.$#'
-			identifier: property.nonObject
-			count: 1
-			path: includes/class-wc-form-handler.php
-
 		-
 			message: '#^Cannot call method get_billing_city\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
 			identifier: method.nonObject
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
new file mode 100644
index 00000000000..cb0026d7d75
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
@@ -0,0 +1,270 @@
+<?php
+/**
+ * Tests for WC_Form_Handler.
+ *
+ * @package WooCommerce\Tests\FormHandler
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * WC_Form_Handler tests.
+ */
+class WC_Form_Handler_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Original POST data.
+	 *
+	 * @var array<string,mixed>
+	 */
+	private array $original_post = array();
+
+	/**
+	 * Original REQUEST data.
+	 *
+	 * @var array<string,mixed>
+	 */
+	private array $original_request = array();
+
+	/**
+	 * Original WooCommerce session.
+	 *
+	 * @var WC_Session|null
+	 */
+	private $original_session;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->original_post    = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing
+		$this->original_request = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		$this->original_session = WC()->session;
+
+		if ( ! WC()->session ) {
+			WC()->initialize_session();
+		}
+
+		add_filter( 'wp_redirect', array( $this, 'intercept_redirect' ) );
+		wc_clear_notices();
+	}
+
+	/**
+	 * Clean up test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'wp_redirect', array( $this, 'intercept_redirect' ) );
+
+		$_POST    = $this->original_post;
+		$_REQUEST = $this->original_request;
+
+		wp_set_current_user( 0 );
+		wc_clear_notices();
+		WC()->session = $this->original_session;
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Intercepts redirects so the tested handler's trailing exit does not run.
+	 *
+	 * @param string $location Redirect target.
+	 * @return never
+	 * @throws RuntimeException Always.
+	 */
+	public function intercept_redirect( string $location ): void {
+		throw new RuntimeException( esc_url_raw( $location ) );
+	}
+
+	/**
+	 * @testdox save_account_details() saves other account fields when an email-like display name is unchanged.
+	 *
+	 * @covers WC_Form_Handler::save_account_details()
+	 */
+	public function test_save_account_details_allows_unchanged_email_like_display_name(): void {
+		$user_email = 'display-name-customer@example.test';
+		$user_id    = self::factory()->user->create(
+			array(
+				'user_login'   => $user_email,
+				'user_email'   => $user_email,
+				'display_name' => $user_email,
+				'role'         => 'customer',
+			)
+		);
+
+		wp_set_current_user( $user_id );
+		$this->prepare_account_details_request(
+			array(
+				'account_first_name'   => 'Jane',
+				'account_last_name'    => 'Doe',
+				'account_display_name' => $user_email,
+				'account_email'        => $user_email,
+			)
+		);
+
+		$this->dispatch_account_details_save_expecting_redirect();
+
+		$updated_user = get_userdata( $user_id );
+
+		$this->assertEmpty( wc_get_notices( 'error' ), 'An unchanged email-like display name should not add error notices.' );
+		$this->assertSame( 'Jane', $updated_user->first_name, 'First name should be saved when the email-like display name is unchanged.' );
+		$this->assertSame( 'Doe', $updated_user->last_name, 'Last name should be saved when the email-like display name is unchanged.' );
+		$this->assertSame( 'Jane Doe', $updated_user->display_name, 'Existing customer sync should continue normalizing email-like display names after the save succeeds.' );
+	}
+
+	/**
+	 * @testdox save_account_details() compares cleaned display names for records that bypassed WordPress sanitization.
+	 *
+	 * @covers WC_Form_Handler::save_account_details()
+	 */
+	public function test_save_account_details_allows_unchanged_email_like_display_name_after_cleaning(): void {
+		global $wpdb;
+
+		$user_email = 'legacy-display-name@example.test';
+		$user_id    = self::factory()->user->create(
+			array(
+				'user_login'   => 'legacy-display-name-customer',
+				'user_email'   => $user_email,
+				'display_name' => 'Display Customer',
+				'role'         => 'customer',
+			)
+		);
+
+		// Simulate a legacy or imported record that bypassed WordPress user-field sanitization.
+		$stored_display_name = "  {$user_email}  ";
+		$wpdb->update(
+			$wpdb->users,
+			array( 'display_name' => $stored_display_name ),
+			array( 'ID' => $user_id )
+		);
+		clean_user_cache( $user_id );
+
+		wp_set_current_user( $user_id );
+		$this->prepare_account_details_request(
+			array(
+				'account_first_name'   => 'Jane',
+				'account_last_name'    => 'Doe',
+				'account_display_name' => $stored_display_name,
+				'account_email'        => $user_email,
+			)
+		);
+
+		$this->dispatch_account_details_save_expecting_redirect();
+
+		$updated_user = get_userdata( $user_id );
+
+		$this->assertSame( 'Jane', $updated_user->first_name, 'First name should be saved when cleaned display names match.' );
+		$this->assertSame( 'Doe', $updated_user->last_name, 'Last name should be saved when cleaned display names match.' );
+	}
+
+	/**
+	 * @testdox save_account_details() still blocks changing the display name to an email address.
+	 *
+	 * @covers WC_Form_Handler::save_account_details()
+	 */
+	public function test_save_account_details_blocks_new_email_like_display_name(): void {
+		$user_id = self::factory()->user->create(
+			array(
+				'user_login'   => 'display-name-customer',
+				'user_email'   => 'display-name-customer@example.test',
+				'first_name'   => 'Original',
+				'last_name'    => 'Customer',
+				'display_name' => 'Display Customer',
+				'role'         => 'customer',
+			)
+		);
+
+		wp_set_current_user( $user_id );
+		$this->prepare_account_details_request(
+			array(
+				'account_first_name'   => 'Jane',
+				'account_last_name'    => 'Doe',
+				'account_display_name' => 'changed-display@example.test',
+				'account_email'        => 'display-name-customer@example.test',
+			)
+		);
+
+		WC_Form_Handler::save_account_details();
+
+		$error_notices = wc_get_notices( 'error' );
+		$updated_user  = get_userdata( $user_id );
+
+		$this->assertCount( 1, $error_notices, 'Changing the display name to an email address should add one validation error.' );
+		$this->assertSame( 'account_display_name', $error_notices[0]['data']['id'] ?? null, 'The validation error should identify the display-name field.' );
+		$this->assertSame( 'Original', $updated_user->first_name, 'First name should not change when account validation fails.' );
+		$this->assertSame( 'Customer', $updated_user->last_name, 'Last name should not change when account validation fails.' );
+		$this->assertSame( 'Display Customer', $updated_user->display_name, 'Display name should not change to a new email-like value.' );
+	}
+
+	/**
+	 * @testdox save_account_details() still allows changing the display name to a non-email value.
+	 *
+	 * @covers WC_Form_Handler::save_account_details()
+	 */
+	public function test_save_account_details_allows_non_email_display_name_change(): void {
+		$user_id = self::factory()->user->create(
+			array(
+				'user_login'   => 'display-name-customer',
+				'user_email'   => 'display-name-customer@example.test',
+				'display_name' => 'Display Customer',
+				'role'         => 'customer',
+			)
+		);
+
+		wp_set_current_user( $user_id );
+		$this->prepare_account_details_request(
+			array(
+				'account_first_name'   => 'Jane',
+				'account_last_name'    => 'Doe',
+				'account_display_name' => 'Updated Customer',
+				'account_email'        => 'display-name-customer@example.test',
+			)
+		);
+
+		$this->dispatch_account_details_save_expecting_redirect();
+
+		$updated_user = get_userdata( $user_id );
+
+		$this->assertEmpty( wc_get_notices( 'error' ), 'A non-email display name should not add error notices.' );
+		$this->assertSame( 'Updated Customer', $updated_user->display_name, 'Display name should save when changed to a non-email value.' );
+	}
+
+	/**
+	 * Prepares request globals for the account details handler.
+	 *
+	 * @param array<string,string> $fields Account detail fields.
+	 */
+	private function prepare_account_details_request( array $fields ): void {
+		$nonce = wp_create_nonce( 'save_account_details' );
+
+		$_POST    = array_merge(
+			array(
+				'action' => 'save_account_details',
+			),
+			$fields
+		);
+		$_REQUEST = array(
+			'save-account-details-nonce' => $nonce,
+		);
+	}
+
+	/**
+	 * Dispatches the account-details save handler and expects its success redirect.
+	 */
+	private function dispatch_account_details_save_expecting_redirect(): void {
+		try {
+			WC_Form_Handler::save_account_details();
+		} catch ( RuntimeException $e ) {
+			$this->assertSame(
+				wc_get_endpoint_url( 'edit-account', '', wc_get_page_permalink( 'myaccount' ) ),
+				$e->getMessage(),
+				'Successful account saves should redirect to Account details.'
+			);
+			return;
+		}
+
+		$this->fail( 'Expected save_account_details() to redirect after a successful save.' );
+	}
+}