Commit 496bc534bdf for woocommerce

commit 496bc534bdf238d8e1be6b6b8dbb66da59a6db07
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Thu Sep 17 16:30:06 2026 +0300

    Delete push tokens and preferences when a user leaves the site (#68466)

    * Delete push tokens and preferences when a user leaves the site

    WordPress does not remove either kind of record for us. wp_delete_user()
    reassigns every post a user owns when an administrator picks "Attribute all
    content to", ignoring the post type's delete_with_user setting, and
    remove_user_from_blog() never deletes posts at all.

    A push token that survives either route keeps the store sending order and
    stock notifications to a device whose owner has gone, under whichever
    eligible user now owns the record. The preferences are user meta and are
    removed in the same call, so a surviving token also falls back to the
    defaults, where every notification type is enabled.

    Both hooks used here fire before the reassignment, so the deletion applies
    whichever option the administrator picks. The cleanup registers ahead of the
    enablement check because tokens stored while the feature was on stay in the
    database once it is off, and must not be waiting there for a later
    reconnection to send against.

    delete_for_user() refuses a non-positive user ID rather than querying with
    it. WP_Query treats an author of 0 as no author filter at all, so without
    the guard a caller that lost the ID would match every token on the site
    rather than none.

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStore.php
index 60afe76314e..b9337c61889 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStore.php
@@ -107,6 +107,22 @@ class NotificationPreferencesDataStore {
 		}
 	}

+	/**
+	 * Delete a user's stored preferences for this site.
+	 *
+	 * Multisite keeps a separate envelope per site, so this removes only the
+	 * one belonging to the current site.
+	 *
+	 * @param int $user_id The user ID.
+	 *
+	 * @return bool True when an envelope was removed, false when there was nothing stored.
+	 *
+	 * @since 11.2.0
+	 */
+	public function delete( int $user_id ): bool {
+		return Users::delete_site_user_meta( $user_id, self::META_KEY );
+	}
+
 	/**
 	 * Upgrade an envelope to the current schema version.
 	 *
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 45cedabe821..141e99269ab 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -195,6 +195,50 @@ class PushTokensDataStore {
 		return (bool) wp_delete_post( (int) $id, true );
 	}

+	/**
+	 * Deletes every push token belonging to a user.
+	 *
+	 * A non-positive ID is refused rather than queried, so a caller that loses
+	 * the user ID cannot match the rows of every author-less token at once.
+	 *
+	 * @since 11.2.0
+	 * @param int $user_id The user whose tokens should be deleted.
+	 * @return int The number of tokens deleted.
+	 */
+	public function delete_for_user( int $user_id ): int {
+		if ( $user_id < 1 ) {
+			return 0;
+		}
+
+		global $wpdb;
+
+		// Direct query so pre_get_posts filters cannot hide a token, and any status is deleted.
+		$post_ids = $wpdb->get_col(
+			$wpdb->prepare(
+				"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_author = %d",
+				PushToken::POST_TYPE,
+				$user_id
+			)
+		);
+
+		if ( empty( $post_ids ) ) {
+			return 0;
+		}
+
+		$deleted = 0;
+
+		foreach ( $post_ids as $post_id ) {
+			if ( wp_delete_post( (int) $post_id, true ) ) {
+				++$deleted;
+			}
+		}
+
+		// Anything read earlier in this request now includes deleted tokens.
+		$this->tokens_by_roles_cache = array();
+
+		return $deleted;
+	}
+
 	/**
 	 * Find tokens for this user and platform that match either the token
 	 * or device UUID. We check the token value to avoid creating a duplicate.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index b237c919186..58c963934e4 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\DriverAvailabilityService;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationRetryHandler;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\UserDataCleanupService;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewReviewNotificationTrigger;
@@ -74,6 +75,12 @@ class PushNotifications {
 		// the state and fall back if needed.
 		wc_get_container()->get( PushNotificationStatusRestController::class )->register();

+		// Also registered ahead of the enablement check. Tokens stored while the
+		// feature was on stay in the database once it is off, and a user deleted
+		// in the meantime must not leave records behind for a later reconnection
+		// to start sending against.
+		wc_get_container()->get( UserDataCleanupService::class )->register();
+
 		if ( ! $this->should_be_enabled() ) {
 			return;
 		}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/UserDataCleanupService.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/UserDataCleanupService.php
new file mode 100644
index 00000000000..3761a054fa8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/UserDataCleanupService.php
@@ -0,0 +1,119 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\NotificationPreferencesDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+
+/**
+ * Deletes a user's push tokens and notification preferences when they are
+ * deleted or removed from the site.
+ *
+ * WordPress does not do this for us. `wp_delete_user()` reassigns every post a
+ * user owns when an administrator chooses "Attribute all content to", ignoring
+ * the post type's `delete_with_user` setting, and `remove_user_from_blog()`
+ * never deletes posts at all. A token that survives either route keeps the site
+ * sending to a device whose owner has gone, under whichever eligible user now
+ * owns the record.
+ *
+ * Both hooks used here fire before the reassignment, so deleting from them
+ * applies whichever option the administrator picks.
+ *
+ * @internal
+ *
+ * @since 11.2.0
+ */
+class UserDataCleanupService {
+	/**
+	 * The push tokens data store.
+	 *
+	 * @var PushTokensDataStore
+	 */
+	private PushTokensDataStore $push_tokens_data_store;
+
+	/**
+	 * The notification preferences data store.
+	 *
+	 * @var NotificationPreferencesDataStore
+	 */
+	private NotificationPreferencesDataStore $preferences_data_store;
+
+	/**
+	 * Initialize dependencies.
+	 *
+	 * @internal
+	 *
+	 * @param PushTokensDataStore              $push_tokens_data_store The push tokens data store.
+	 * @param NotificationPreferencesDataStore $preferences_data_store The notification preferences data store.
+	 *
+	 * @since 11.2.0
+	 */
+	final public function init(
+		PushTokensDataStore $push_tokens_data_store,
+		NotificationPreferencesDataStore $preferences_data_store
+	): void {
+		$this->push_tokens_data_store = $push_tokens_data_store;
+		$this->preferences_data_store = $preferences_data_store;
+	}
+
+	/**
+	 * Registers the WordPress hooks for user deletion and removal.
+	 *
+	 * `wpmu_delete_user` needs no handling of its own because it calls
+	 * `remove_user_from_blog()` for every site the user belongs to.
+	 *
+	 * @return void
+	 *
+	 * @since 11.2.0
+	 */
+	public function register(): void {
+		add_action( 'delete_user', array( $this, 'handle_delete_user' ) );
+		add_action( 'remove_user_from_blog', array( $this, 'handle_remove_user_from_blog' ) );
+	}
+
+	/**
+	 * Handles the delete_user hook.
+	 *
+	 * @internal
+	 *
+	 * @param int $user_id The ID of the user being deleted.
+	 * @return void
+	 *
+	 * @since 11.2.0
+	 */
+	public function handle_delete_user( int $user_id ): void {
+		$this->delete_data_for_user( $user_id );
+	}
+
+	/**
+	 * Handles the remove_user_from_blog hook.
+	 *
+	 * WordPress fires this inside `switch_to_blog()`, so the site-scoped reads
+	 * and writes below already target the site the user is leaving.
+	 *
+	 * @internal
+	 *
+	 * @param int $user_id The ID of the user being removed from the site.
+	 * @return void
+	 *
+	 * @since 11.2.0
+	 */
+	public function handle_remove_user_from_blog( int $user_id ): void {
+		$this->delete_data_for_user( $user_id );
+	}
+
+	/**
+	 * Deletes both kinds of stored push notification data for a user.
+	 *
+	 * @param int $user_id The user ID.
+	 * @return void
+	 */
+	private function delete_data_for_user( int $user_id ): void {
+		$this->push_tokens_data_store->delete_for_user( $user_id );
+		$this->preferences_data_store->delete( $user_id );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStoreTest.php
index 873b17db0c8..81d679ed854 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/NotificationPreferencesDataStoreTest.php
@@ -164,4 +164,27 @@ class NotificationPreferencesDataStoreTest extends WC_Unit_Test_Case {
 		$stored = Users::get_site_user_meta( $this->user_id, NotificationPreferencesDataStore::META_KEY );
 		$this->assertSame( $envelope, $stored );
 	}
+
+	/**
+	 * @testdox Should remove a stored envelope and report that it did so.
+	 */
+	public function test_delete_removes_a_stored_envelope(): void {
+		$this->sut->write(
+			$this->user_id,
+			array(
+				'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+				'preferences'    => array( 'store_order' => array( 'enabled' => false ) ),
+			)
+		);
+
+		$this->assertTrue( $this->sut->delete( $this->user_id ) );
+		$this->assertNull( $this->sut->read( $this->user_id ) );
+	}
+
+	/**
+	 * @testdox Should report false when the user has no stored envelope to remove.
+	 */
+	public function test_delete_returns_false_when_nothing_is_stored(): void {
+		$this->assertFalse( $this->sut->delete( $this->user_id ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
index 2173cae36f9..4b93f8f5c0e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -959,6 +959,83 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should delete every token owned by the user and report how many went.
+	 */
+	public function test_delete_for_user_deletes_all_of_that_users_tokens(): void {
+		$data_store = new PushTokensDataStore();
+
+		$this->create_push_token_for_user( $data_store, 101 );
+		$this->create_push_token_for_user( $data_store, 101 );
+		$retained = $this->create_push_token_for_user( $data_store, 102 );
+
+		$this->assertSame( 2, $data_store->delete_for_user( 101 ) );
+		$this->assertSame( 0, $this->count_tokens_for_user( 101 ) );
+		$this->assertNotNull( get_post( $retained->get_id() ) );
+	}
+
+	/**
+	 * @testdox Should report zero when the user owns no tokens.
+	 */
+	public function test_delete_for_user_returns_zero_when_the_user_has_no_tokens(): void {
+		$data_store = new PushTokensDataStore();
+
+		$this->assertSame( 0, $data_store->delete_for_user( 103 ) );
+	}
+
+	/**
+	 * A caller that loses the user ID must not match every author-less row.
+	 *
+	 * @testdox Should refuse a non-positive user ID and delete nothing.
+	 */
+	public function test_delete_for_user_refuses_a_non_positive_user_id(): void {
+		global $wpdb;
+
+		$data_store = new PushTokensDataStore();
+		$this->create_push_token_for_user( $data_store, 105 );
+
+		$wpdb->query(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->posts} SET post_author = 0 WHERE post_type = %s",
+				PushToken::POST_TYPE
+			)
+		);
+
+		$this->assertSame( 0, $data_store->delete_for_user( 0 ) );
+		$this->assertSame( 1, $this->count_tokens_for_user( 0 ) );
+	}
+
+	/**
+	 * @testdox Should remove the token's meta along with the record.
+	 */
+	public function test_delete_for_user_removes_token_meta(): void {
+		$data_store = new PushTokensDataStore();
+		$push_token = $this->create_push_token_for_user( $data_store, 104 );
+		$token_id   = $push_token->get_id();
+
+		$data_store->delete_for_user( 104 );
+
+		$this->assertSame( '', get_post_meta( $token_id, 'token', true ) );
+	}
+
+	/**
+	 * Counts the push token records owned by a user.
+	 *
+	 * @param int $user_id The owning user ID.
+	 * @return int The number of records.
+	 */
+	private function count_tokens_for_user( int $user_id ): int {
+		global $wpdb;
+
+		return (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_author = %d",
+				PushToken::POST_TYPE,
+				$user_id
+			)
+		);
+	}
+
 	/**
 	 * Creates a test push token and saves it to the database.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/UserDataCleanupServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/UserDataCleanupServiceTest.php
new file mode 100644
index 00000000000..acaf4d56a6f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/UserDataCleanupServiceTest.php
@@ -0,0 +1,238 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\NotificationPreferencesDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\UserDataCleanupService;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the UserDataCleanupService class.
+ *
+ * @covers \Automattic\WooCommerce\Internal\PushNotifications\Services\UserDataCleanupService
+ */
+class UserDataCleanupServiceTest extends WC_Unit_Test_Case {
+	/**
+	 * The System Under Test.
+	 *
+	 * @var UserDataCleanupService
+	 */
+	private UserDataCleanupService $sut;
+
+	/**
+	 * The push tokens data store.
+	 *
+	 * @var PushTokensDataStore
+	 */
+	private PushTokensDataStore $push_tokens_data_store;
+
+	/**
+	 * The notification preferences data store.
+	 *
+	 * @var NotificationPreferencesDataStore
+	 */
+	private NotificationPreferencesDataStore $preferences_data_store;
+
+	/**
+	 * User IDs created by the test, deleted in tearDown.
+	 *
+	 * @var int[]
+	 */
+	private array $user_ids = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->push_tokens_data_store = new PushTokensDataStore();
+		$this->preferences_data_store = new NotificationPreferencesDataStore();
+		$this->sut                    = wc_get_container()->get( UserDataCleanupService::class );
+	}
+
+	/**
+	 * Remove every push token and test user created during the test.
+	 */
+	public function tearDown(): void {
+		global $wpdb;
+
+		$wpdb->query(
+			$wpdb->prepare(
+				"DELETE postmeta FROM {$wpdb->postmeta} postmeta
+				LEFT JOIN {$wpdb->posts} posts ON postmeta.post_id = posts.ID
+				WHERE posts.post_type = %s",
+				PushToken::POST_TYPE
+			)
+		);
+
+		$wpdb->query(
+			$wpdb->prepare(
+				"DELETE FROM {$wpdb->posts} WHERE post_type = %s",
+				PushToken::POST_TYPE
+			)
+		);
+
+		foreach ( $this->user_ids as $user_id ) {
+			if ( get_userdata( $user_id ) ) {
+				wp_delete_user( $user_id );
+			}
+		}
+
+		$this->user_ids = array();
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should register both the user deletion and the site removal hooks.
+	 */
+	public function test_register_adds_both_hooks(): void {
+		$this->sut->register();
+
+		$this->assertNotFalse( has_action( 'delete_user', array( $this->sut, 'handle_delete_user' ) ) );
+		$this->assertNotFalse( has_action( 'remove_user_from_blog', array( $this->sut, 'handle_remove_user_from_blog' ) ) );
+	}
+
+	/**
+	 * @testdox Should delete the user's push tokens and preferences when they are deleted.
+	 */
+	public function test_handle_delete_user_deletes_tokens_and_preferences(): void {
+		$user_id = $this->create_user();
+		$this->create_token_for( $user_id );
+		$this->preferences_data_store->write( $user_id, $this->preferences_envelope() );
+
+		$this->sut->handle_delete_user( $user_id );
+
+		$this->assertSame( 0, $this->count_tokens_for( $user_id ) );
+		$this->assertNull( $this->preferences_data_store->read( $user_id ) );
+	}
+
+	/**
+	 * @testdox Should delete the user's push tokens and preferences when they are removed from the site.
+	 */
+	public function test_handle_remove_user_from_blog_deletes_tokens_and_preferences(): void {
+		$user_id = $this->create_user();
+		$this->create_token_for( $user_id );
+		$this->preferences_data_store->write( $user_id, $this->preferences_envelope() );
+
+		$this->sut->handle_remove_user_from_blog( $user_id );
+
+		$this->assertSame( 0, $this->count_tokens_for( $user_id ) );
+		$this->assertNull( $this->preferences_data_store->read( $user_id ) );
+	}
+
+	/**
+	 * @testdox Should leave other users' push tokens and preferences alone.
+	 */
+	public function test_handle_delete_user_leaves_other_users_untouched(): void {
+		$deleted_user_id  = $this->create_user();
+		$retained_user_id = $this->create_user();
+
+		$this->create_token_for( $deleted_user_id );
+		$this->create_token_for( $retained_user_id );
+		$this->preferences_data_store->write( $retained_user_id, $this->preferences_envelope() );
+
+		$this->sut->handle_delete_user( $deleted_user_id );
+
+		$this->assertSame( 1, $this->count_tokens_for( $retained_user_id ) );
+		$this->assertNotNull( $this->preferences_data_store->read( $retained_user_id ) );
+	}
+
+	/**
+	 * The reassignment branch of `wp_delete_user()` moves every post the user
+	 * owns to the reassignee regardless of `delete_with_user`, so without the
+	 * cleanup the tokens would survive under an eligible owner.
+	 *
+	 * @testdox Should delete push tokens rather than reassign them when content is attributed to another user.
+	 */
+	public function test_tokens_are_not_reassigned_when_a_deleted_user_has_content_attributed(): void {
+		$deleted_user_id    = $this->create_user();
+		$reassigned_user_id = $this->create_user();
+
+		$this->create_token_for( $deleted_user_id );
+
+		require_once ABSPATH . 'wp-admin/includes/user.php';
+		wp_delete_user( $deleted_user_id, $reassigned_user_id );
+
+		$this->assertSame( 0, $this->count_tokens_for( $deleted_user_id ) );
+		$this->assertSame( 0, $this->count_tokens_for( $reassigned_user_id ) );
+	}
+
+	/**
+	 * @testdox Should not fail when the user has no push tokens or preferences stored.
+	 */
+	public function test_handle_delete_user_is_a_no_op_for_a_user_with_no_data(): void {
+		$user_id = $this->create_user();
+
+		$this->sut->handle_delete_user( $user_id );
+
+		$this->assertSame( 0, $this->count_tokens_for( $user_id ) );
+	}
+
+	/**
+	 * Creates an administrator and records it for removal in tearDown.
+	 *
+	 * @return int The new user ID.
+	 */
+	private function create_user(): int {
+		$user_id          = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		$this->user_ids[] = $user_id;
+
+		return $user_id;
+	}
+
+	/**
+	 * Creates a push token owned by the given user.
+	 *
+	 * @param int $user_id The owning user ID.
+	 * @return PushToken The created token.
+	 */
+	private function create_token_for( int $user_id ): PushToken {
+		return $this->push_tokens_data_store->create(
+			array(
+				'user_id'       => $user_id,
+				'token'         => 'test_token_' . wp_rand(),
+				'platform'      => PushToken::PLATFORM_APPLE,
+				'device_uuid'   => 'test-device-uuid-' . wp_rand(),
+				'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+				'device_locale' => 'en_US',
+				'metadata'      => array( 'app_version' => '1.0' ),
+			)
+		);
+	}
+
+	/**
+	 * Counts the push token records owned by a user.
+	 *
+	 * @param int $user_id The owning user ID.
+	 * @return int The number of records.
+	 */
+	private function count_tokens_for( int $user_id ): int {
+		global $wpdb;
+
+		return (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_author = %d",
+				PushToken::POST_TYPE,
+				$user_id
+			)
+		);
+	}
+
+	/**
+	 * A minimal valid preferences envelope.
+	 *
+	 * @return array
+	 */
+	private function preferences_envelope(): array {
+		return array(
+			'schema_version' => NotificationPreferencesDataStore::CURRENT_SCHEMA_VERSION,
+			'preferences'    => array( 'store_order' => array( 'enabled' => false ) ),
+		);
+	}
+}