Commit 6eb352276b6 for woocommerce

commit 6eb352276b68681456559f4e01a1ad56c023c47e
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Tue Sep 22 17:47:29 2026 +0300

    Make the Back in Stock duplicate sign-up lookup a fixed cost (#68915)

    * fix: match BIS duplicate sign-ups with one exact query, validate attributes

    * fix: keep a fixed BIS variation attribute valued '0' out of the posted set

    * fix: sort BIS posted attributes by key so duplicate matching ignores order

    * fix: reject non-string BIS attribute values before the string sanitizers

diff --git a/plugins/woocommerce/changelog/fix-bis-bounded-duplicate-signup-lookup b/plugins/woocommerce/changelog/fix-bis-bounded-duplicate-signup-lookup
new file mode 100644
index 00000000000..f7d731e7629
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-bis-bounded-duplicate-signup-lookup
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Validate posted Back in Stock variation attributes against the values the store offers, look up an existing sign-up with a single exact query, and check the sign-up rate limit before it.
diff --git a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
index 33b79561e17..6cc1e782235 100644
--- a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
@@ -605,6 +605,60 @@ CREATE TABLE $meta_table_name (
 		return (int) $wpdb->get_var( $sql ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
 	}

+	/**
+	 * Get the ID of the active or pending notification matching an identity, product and posted
+	 * attribute set.
+	 *
+	 * The posted attributes are matched as the serialized string they are stored as, so the
+	 * comparison is exact and case sensitive, but not sensitive to key order: both sides are
+	 * sorted by key. An empty set matches any sign-up for the identity.
+	 *
+	 * @param int    $product_id The product ID.
+	 * @param int    $user_id The user ID, or 0 to match on the email instead.
+	 * @param string $user_email The email address, used when no user ID is given.
+	 * @param array  $posted_attributes The posted attributes to match.
+	 * @return int The notification ID, or 0 when nothing matches.
+	 */
+	public function get_matching_notification_id( int $product_id, int $user_id, string $user_email, array $posted_attributes = array() ): int {
+
+		if ( empty( $product_id ) ) {
+			return 0;
+		}
+
+		if ( empty( $user_id ) ) {
+			$user_email = EmailNormalizer::normalize( $user_email );
+			if ( ! is_email( $user_email ) ) {
+				return 0;
+			}
+		}
+
+		global $wpdb;
+
+		$table          = $this->get_table_name();
+		$identity_where = empty( $user_id ) ? 'user_email = %s' : 'user_id = %d';
+		$identity_value = empty( $user_id ) ? $user_email : $user_id;
+
+		if ( empty( $posted_attributes ) ) {
+			$sql = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+				"SELECT id FROM $table WHERE product_id = %d AND $identity_where AND status IN (%s, %s) LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+				array( $product_id, $identity_value, NotificationStatus::ACTIVE, NotificationStatus::PENDING )
+			);
+
+			return absint( $wpdb->get_var( $sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+		}
+
+		// Sort by key so the serialized blob is the same whatever order the caller built the set in.
+		ksort( $posted_attributes );
+
+		$meta_table = $this->get_meta_table_name();
+		$sql        = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+			"SELECT notifications.id FROM $table AS notifications INNER JOIN $meta_table AS meta ON meta.notification_id = notifications.id AND meta.meta_key = %s WHERE notifications.product_id = %d AND notifications.$identity_where AND notifications.status IN (%s, %s) AND BINARY meta.meta_value = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+			array( 'posted_attributes', $product_id, $identity_value, NotificationStatus::ACTIVE, NotificationStatus::PENDING, maybe_serialize( $posted_attributes ) )
+		);
+
+		return absint( $wpdb->get_var( $sql ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+	}
+
 	/**
 	 * Get distinct notification creation dates.
 	 *
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
index 03a7b7a6eb9..134db09c2d4 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
@@ -50,6 +50,11 @@ class SignupService {
 	public const ERROR_INVALID_OPT_IN = 'invalid_opt_in';
 	// phpcs:enable

+	/**
+	 * Maximum length allowed for a single posted attribute value.
+	 */
+	private const MAX_ATTRIBUTE_LENGTH = 255;
+
 	/**
 	 * Eligibility service.
 	 *
@@ -105,6 +110,9 @@ class SignupService {
 	 * exception raised further down still holds the customer back until the window expires. A
 	 * window that cannot be claimed at all is the exception, and lets the sign-up through.
 	 *
+	 * The rate limit is checked before the duplicate sign-up lookup, so a limited request is
+	 * rejected without also paying for that lookup.
+	 *
 	 * @param int    $product_id The product ID.
 	 * @param int    $user_id The user ID.
 	 * @param string $user_email The user email.
@@ -141,9 +149,13 @@ class SignupService {
 			return new \WP_Error( self::ERROR_INVALID_PRODUCT );
 		}

-		// Attempts that only find an existing active or pending sign-up, or activate an existing
-		// pending one, create nothing new and send no verification mail, so they are answered
-		// before the rate limit is consulted or claimed.
+		if ( SignupRateLimiter::is_rate_limited( $user_email ) ) {
+			return new \WP_Error( self::ERROR_RATE_LIMITED );
+		}
+
+		// An existing rate limit window blocks these attempts too, but attempts that only find
+		// an existing active or pending sign-up, or activate an existing pending one, never claim
+		// a window themselves: they create nothing new and send no verification mail.
 		$notification = $this->is_already_signed_up( $product_id, $user_id, $user_email, $posted_attributes );
 		if ( $notification instanceof Notification ) {
 			if ( NotificationStatus::ACTIVE === $notification->get_status() ) {
@@ -174,10 +186,6 @@ class SignupService {
 			}
 		}

-		if ( SignupRateLimiter::is_rate_limited( $user_email ) ) {
-			return new \WP_Error( self::ERROR_RATE_LIMITED );
-		}
-
 		// Claim the rate limit window before storing a notification or sending mail. This
 		// narrows the window in which two near-simultaneous requests both get through; it
 		// does not close it.
@@ -199,6 +207,8 @@ class SignupService {
 		$notification->set_user_email( $user_email );

 		if ( ! empty( $posted_attributes ) ) {
+			// Sort by key so the stored blob matches what the duplicate lookup serializes.
+			ksort( $posted_attributes );
 			$notification->update_meta_data( 'posted_attributes', $posted_attributes );
 		}

@@ -253,64 +263,31 @@ class SignupService {
 		// email, or vice versa. Match on user ID first, then fall back to the email so both states are found.
 		$identities = array();
 		if ( ! empty( $user_id ) ) {
-			$identities[] = array( 'user_id' => $user_id );
+			$identities[] = array(
+				'user_id'    => $user_id,
+				'user_email' => '',
+			);
 		}
 		if ( ! empty( $user_email ) ) {
-			$identities[] = array( 'user_email' => $user_email );
+			$identities[] = array(
+				'user_id'    => 0,
+				'user_email' => $user_email,
+			);
 		}

 		foreach ( $identities as $identity ) {
-			$notifications = NotificationQuery::get_notifications(
-				array_merge(
-					$identity,
-					array(
-						'product_id' => $product_id,
-						'status'     => array( NotificationStatus::ACTIVE, NotificationStatus::PENDING ),
-						'order_by'   => array( 'id' => 'DESC' ),
-						'return'     => 'objects',
-					)
-				)
-			);
-
-			foreach ( $notifications as $notification ) {
-				if ( $notification instanceof Notification && $this->matches_posted_attributes( $notification, $posted_attributes ) ) {
-					return $notification;
-				}
+			$notification_id = NotificationQuery::get_matching_notification_id( $product_id, $identity['user_id'], $identity['user_email'], $posted_attributes );
+			if ( empty( $notification_id ) ) {
+				continue;
 			}
-		}
-
-		return null;
-	}
-
-	/**
-	 * Check whether a notification was signed up for with the posted attributes.
-	 *
-	 * A variation with "any" attributes can be signed up for more than once with different
-	 * attribute values, so the stored attributes have to match the posted ones. An empty set
-	 * of posted attributes matches any notification.
-	 *
-	 * @param Notification $notification The notification.
-	 * @param array        $posted_attributes The posted attributes.
-	 * @return bool True if the notification matches the posted attributes.
-	 */
-	private function matches_posted_attributes( Notification $notification, array $posted_attributes ): bool {
-
-		if ( empty( $posted_attributes ) ) {
-			return true;
-		}
-
-		$stored_attributes = $notification->get_meta( 'posted_attributes' );
-		if ( ! is_array( $stored_attributes ) || count( $stored_attributes ) !== count( $posted_attributes ) ) {
-			return false;
-		}

-		foreach ( $posted_attributes as $key => $value ) {
-			if ( ! array_key_exists( $key, $stored_attributes ) || (string) $stored_attributes[ $key ] !== (string) $value ) {
-				return false;
+			$notification = Factory::get_notification( $notification_id );
+			if ( $notification instanceof Notification ) {
+				return $notification;
 			}
 		}

-		return true;
+		return null;
 	}

 	/**
@@ -341,6 +318,9 @@ class SignupService {
 		$parsed_data['product_id'] = $product->get_id();
 		if ( $product instanceof \WC_Product_Variation ) {
 			$posted_attributes = $this->parse_posted_attributes( $source, $product );
+			if ( \is_wp_error( $posted_attributes ) ) {
+				return $posted_attributes;
+			}

 			if ( ! empty( $posted_attributes ) ) {
 				$parsed_data['posted_attributes'] = $posted_attributes;
@@ -426,13 +406,21 @@ class SignupService {
 	 * For example, if a t-shirt variation has 'any' size but a specific color, we need to capture
 	 * the chosen size from the form submission while the color comes from the variation itself.
 	 *
-	 * @see \WC_Cart::add_to_cart() for similar attribute parsing logic.
+	 * Only 'any' attributes are read from the request. Every attribute the variation fixes is
+	 * already identified by the variation ID, so a posted value for one carries no information
+	 * and is ignored.
+	 *
+	 * Posted values are checked against the attribute's declared values, the same way
+	 * `WC_Cart::add_to_cart()` checks them for an 'any' attribute, so a request cannot store a
+	 * value the store never offered and mint a sign-up row that no later request can match.
+	 *
+	 * @see \WC_Cart::add_to_cart() for similar attribute parsing and validation logic.
 	 *
 	 * @param array       $source The source data, e.g. $_POST or $_REQUEST.
 	 * @param \WC_Product $variation The variation.
-	 * @return array The posted attributes.
+	 * @return array|\WP_Error The posted attributes, or a WP_Error if a posted value is too long or not one the store offers.
 	 */
-	private function parse_posted_attributes( array $source, \WC_Product $variation ): array {
+	private function parse_posted_attributes( array $source, \WC_Product $variation ) {

 		if ( ! $variation instanceof \WC_Product_Variation ) {
 			return array();
@@ -443,34 +431,46 @@ class SignupService {
 			return array();
 		}

+		// Empty values are the 'any' attributes, so what is left is the set the variation fixes.
+		$fixed_attributes = array_filter( $variation->get_variation_attributes(), 'wc_array_filter_default_attributes' );
+
 		$posted_attributes = array();
 		foreach ( $product->get_attributes() as $attribute ) {
-			if ( ! $attribute['is_variation'] ) {
+			if ( ! $attribute instanceof \WC_Product_Attribute || ! $attribute['is_variation'] ) {
 				continue;
 			}

 			$attribute_key = 'attribute_' . sanitize_title( $attribute['name'] );
-			if ( isset( $source[ $attribute_key ] ) ) {
-				if ( $attribute['is_taxonomy'] ) {
-					$value = sanitize_title( wp_unslash( $source[ $attribute_key ] ) );
-				} else {
-					$value = html_entity_decode( wc_clean( wp_unslash( $source[ $attribute_key ] ) ), ENT_QUOTES, get_bloginfo( 'charset' ) );
-				}
+			if ( isset( $fixed_attributes[ $attribute_key ] ) || ! isset( $source[ $attribute_key ] ) ) {
+				continue;
+			}

-				// Don't include if it's empty.
-				if ( ! empty( $value ) || '0' === $value ) {
-					$posted_attributes[ $attribute_key ] = $value;
-				}
+			// A request can post the value as an array, which the string sanitizers below cannot take.
+			$raw_value = wp_unslash( $source[ $attribute_key ] );
+			if ( ! is_string( $raw_value ) ) {
+				return new \WP_Error( self::ERROR_INVALID_REQUEST );
+			}
+
+			if ( $attribute['is_taxonomy'] ) {
+				$value = sanitize_title( $raw_value );
+			} else {
+				$value = html_entity_decode( wc_clean( $raw_value ), ENT_QUOTES, get_bloginfo( 'charset' ) );
 			}
-		}

-		$variation_attributes = $variation->get_variation_attributes();
-		// Filter out 'any' variations, which are empty.
-		$variation_attributes = array_filter( $variation_attributes );
-		$diff                 = array_diff( $posted_attributes, $variation_attributes );
+			// Don't include if it's empty.
+			if ( empty( $value ) && '0' !== $value ) {
+				continue;
+			}
+
+			// Length first, so an oversized value is rejected without reading the declared ones.
+			if ( strlen( $value ) > self::MAX_ATTRIBUTE_LENGTH || ! in_array( $value, $attribute->get_slugs(), true ) ) {
+				return new \WP_Error( self::ERROR_INVALID_REQUEST );
+			}
+
+			$posted_attributes[ $attribute_key ] = $value;
+		}

-		// Return the posted attributes only if a variation with `any` attribute is detected.
-		return ! empty( $diff ) ? $diff : array();
+		return $posted_attributes;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php b/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php
index 6309ad134e9..56889e74ad9 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/NotificationQuery.php
@@ -94,6 +94,23 @@ class NotificationQuery {
 		return $data_store ? $data_store->notification_exists_by_email( $product_id, $email ) : false;
 	}

+	/**
+	 * Get the ID of the active or pending notification matching an identity, product and posted
+	 * attribute set.
+	 *
+	 * @param int    $product_id The product ID.
+	 * @param int    $user_id The user ID, or 0 to match on the email instead.
+	 * @param string $user_email The email address, used when no user ID is given.
+	 * @param array  $posted_attributes The posted attributes to match.
+	 * @return int The notification ID, or 0 when nothing matches.
+	 */
+	public static function get_matching_notification_id( int $product_id, int $user_id, string $user_email, array $posted_attributes = array() ): int {
+		$data_store = self::load_data_store();
+
+		// @phpstan-ignore method.notFound (the call is proxied by WC_Data_Store::__call())
+		return $data_store ? absint( $data_store->get_matching_notification_id( $product_id, $user_id, $user_email, $posted_attributes ) ) : 0;
+	}
+
 	/**
 	 * Get a notification by user ID.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
index 4036810ba8a..9cada6a3654 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
@@ -3,6 +3,7 @@
 declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Frontend;

+use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\NotificationManagementService;
@@ -13,6 +14,9 @@ use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityServ
 use Automattic\WooCommerce\Internal\StockNotifications\Utilities\StockManagementHelper;
 use Automattic\WooCommerce\Tests\Internal\StockNotifications\StockNotificationsFeatureTrait;
 use WC_Helper_Product;
+use WC_Product_Attribute;
+use WC_Product_Variable;
+use WC_Product_Variation;

 /**
  * Tests for SignupService email dispatch.
@@ -132,6 +136,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
 	 * @testdox Should detect an existing guest signup when the same email later signs up as a logged-in user.
 	 */
 	public function test_guest_signup_detected_for_logged_in_user_with_same_email() {
+		$this->disable_signup_rate_limiting();
 		update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );

 		$product = $this->create_out_of_stock_product();
@@ -154,6 +159,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
 	 * @testdox Should detect an existing pending guest signup when the same email later signs up as a logged-in user with double opt-in enabled.
 	 */
 	public function test_pending_guest_signup_detected_for_logged_in_user_with_double_opt_in() {
+		$this->disable_signup_rate_limiting();
 		update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'yes' );

 		$product = $this->create_out_of_stock_product();
@@ -172,6 +178,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
 	 * @testdox Should detect an existing logged-in signup when the same email later signs up as a guest.
 	 */
 	public function test_logged_in_signup_detected_for_guest_with_same_email() {
+		$this->disable_signup_rate_limiting();
 		update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );

 		$product = $this->create_out_of_stock_product();
@@ -189,6 +196,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
 	 * @testdox Should detect an existing guest signup when the same email later signs up as a logged-in user with the same attributes.
 	 */
 	public function test_guest_signup_detected_for_logged_in_user_with_same_attributes() {
+		$this->disable_signup_rate_limiting();
 		update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );

 		$product = $this->create_out_of_stock_product();
@@ -463,7 +471,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
 	 */
 	private function create_out_of_stock_product(): \WC_Product_Simple {
 		$product = WC_Helper_Product::create_simple_product();
-		$product->set_stock_status( 'outofstock' );
+		$product->set_stock_status( ProductStockStatus::OUT_OF_STOCK );
 		$product->save();

 		return $product;
@@ -473,6 +481,7 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
 	 * @testdox A second signup with a different-case email should be reported as already joined.
 	 */
 	public function test_signup_dedupes_case_variants(): void {
+		$this->disable_signup_rate_limiting();
 		update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );

 		$product = $this->create_out_of_stock_product();
@@ -573,4 +582,257 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
 		$this->assertInstanceOf( \WP_Error::class, $data );
 		$this->assertSame( SignupService::ERROR_INVALID_EMAIL, $data->get_error_code() );
 	}
+
+	/**
+	 * @testdox parse() should reject a posted variation attribute value the store does not offer.
+	 */
+	public function test_parse_rejects_value_not_in_attribute_options(): void {
+		$variation = $this->create_out_of_stock_variation_with_any_attribute();
+
+		$data = $this->sut->parse(
+			array(
+				'wc_bis_product_id' => $variation->get_id(),
+				'wc_bis_email'      => 'guest@example.com',
+				'attribute_finish'  => 'chrome',
+			)
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $data );
+		$this->assertSame( SignupService::ERROR_INVALID_REQUEST, $data->get_error_code() );
+	}
+
+	/**
+	 * @testdox parse() should reject a posted variation attribute value over 255 characters.
+	 */
+	public function test_parse_rejects_oversized_attribute_value(): void {
+		$value     = str_repeat( 'a', 256 );
+		$variation = $this->create_out_of_stock_variation_with_any_attribute( array( $value ) );
+
+		$data = $this->sut->parse(
+			array(
+				'wc_bis_product_id' => $variation->get_id(),
+				'wc_bis_email'      => 'guest@example.com',
+				'attribute_finish'  => $value,
+			)
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $data );
+		$this->assertSame( SignupService::ERROR_INVALID_REQUEST, $data->get_error_code() );
+	}
+
+	/**
+	 * @testdox parse() should accept a posted variation attribute value at the 255 character limit.
+	 */
+	public function test_parse_accepts_attribute_value_at_the_limit(): void {
+		$value     = str_repeat( 'a', 255 );
+		$variation = $this->create_out_of_stock_variation_with_any_attribute( array( $value ) );
+
+		$data = $this->sut->parse(
+			array(
+				'wc_bis_product_id' => $variation->get_id(),
+				'wc_bis_email'      => 'guest@example.com',
+				'attribute_finish'  => $value,
+			)
+		);
+
+		$this->assertIsArray( $data );
+		$this->assertArrayHasKey( 'posted_attributes', $data );
+		$this->assertSame( $value, $data['posted_attributes']['attribute_finish'] );
+	}
+
+	/**
+	 * @testdox Should block an already-joined product by an existing rate limit window rather than report already-joined.
+	 */
+	public function test_already_joined_is_blocked_by_an_existing_rate_limit_window(): void {
+		$email     = 'guest@example.com';
+		$product_a = $this->create_out_of_stock_product();
+		$product_b = $this->create_out_of_stock_product();
+
+		$notification = new Notification();
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->set_product_id( $product_a->get_id() );
+		$notification->set_user_email( $email );
+		$notification->save();
+
+		$claims_window = $this->sut->signup( $product_b->get_id(), 0, $email );
+		$this->assertSame( SignupService::SIGNUP_SUCCESS, $claims_window->get_code(), 'Signing up for a different product should succeed and claim the rate limit window' );
+
+		$result = $this->sut->signup( $product_a->get_id(), 0, $email );
+		$this->assertWPError( $result, 'A request within the rate limit window should fail even for a product the customer already joined' );
+		$this->assertSame( SignupService::ERROR_RATE_LIMITED, $result->get_error_code(), 'The rate limit should be reported before the duplicate lookup runs' );
+	}
+
+	/**
+	 * @testdox parse() should ignore a posted value for an attribute the variation fixes.
+	 */
+	public function test_parse_ignores_posted_values_for_fixed_attributes(): void {
+		$variation = $this->create_out_of_stock_variation_with_fixed_and_any_attributes();
+
+		$data = $this->sut->parse(
+			array(
+				'wc_bis_product_id' => $variation->get_id(),
+				'wc_bis_email'      => 'guest@example.com',
+				'attribute_color'   => 'blue',
+				'attribute_finish'  => 'gloss',
+			)
+		);
+
+		$this->assertIsArray( $data );
+		$this->assertSame( array( 'attribute_finish' => 'gloss' ), $data['posted_attributes'], 'The variation ID already identifies the attributes it fixes, so a posted value for one should be dropped' );
+	}
+
+	/**
+	 * @testdox Should find an existing sign-up stored with the same posted attributes.
+	 */
+	public function test_lookup_matches_the_same_posted_attributes(): void {
+		$product = $this->create_out_of_stock_product();
+		$email   = 'guest@example.com';
+
+		$stored = $this->create_active_notification_with_attributes( $product->get_id(), $email, array( 'attribute_size' => 'large' ) );
+
+		$found = $this->sut->is_already_signed_up( $product->get_id(), 0, $email, array( 'attribute_size' => 'large' ) );
+
+		$this->assertInstanceOf( Notification::class, $found );
+		$this->assertSame( $stored->get_id(), $found->get_id() );
+	}
+
+	/**
+	 * @testdox Should not find a sign-up stored with different posted attributes.
+	 */
+	public function test_lookup_does_not_match_different_posted_attributes(): void {
+		$product = $this->create_out_of_stock_product();
+		$email   = 'guest@example.com';
+
+		$this->create_active_notification_with_attributes( $product->get_id(), $email, array( 'attribute_size' => 'large' ) );
+
+		$this->assertNull( $this->sut->is_already_signed_up( $product->get_id(), 0, $email, array( 'attribute_size' => 'small' ) ), 'A different value should not match' );
+		$this->assertNull(
+			$this->sut->is_already_signed_up(
+				$product->get_id(),
+				0,
+				$email,
+				array(
+					'attribute_size'  => 'large',
+					'attribute_color' => 'blue',
+				)
+			),
+			'A superset of the stored attributes should not match'
+		);
+	}
+
+	/**
+	 * @testdox Should match any sign-up for the identity when no attributes are posted.
+	 */
+	public function test_lookup_without_posted_attributes_matches_any_signup(): void {
+		$product = $this->create_out_of_stock_product();
+		$email   = 'guest@example.com';
+
+		$stored = $this->create_active_notification_with_attributes( $product->get_id(), $email, array( 'attribute_size' => 'large' ) );
+
+		$found = $this->sut->is_already_signed_up( $product->get_id(), 0, $email );
+
+		$this->assertInstanceOf( Notification::class, $found );
+		$this->assertSame( $stored->get_id(), $found->get_id() );
+	}
+
+	/**
+	 * @testdox Should match posted attributes case sensitively.
+	 */
+	public function test_lookup_matches_posted_attributes_case_sensitively(): void {
+		$product = $this->create_out_of_stock_product();
+		$email   = 'guest@example.com';
+
+		$this->create_active_notification_with_attributes( $product->get_id(), $email, array( 'attribute_size' => 'Large' ) );
+
+		$this->assertNull( $this->sut->is_already_signed_up( $product->get_id(), 0, $email, array( 'attribute_size' => 'large' ) ), 'A value differing only in case should not match' );
+		$this->assertInstanceOf( Notification::class, $this->sut->is_already_signed_up( $product->get_id(), 0, $email, array( 'attribute_size' => 'Large' ) ) );
+	}
+
+	/**
+	 * Create a variable product with one fixed and one "any" attribute, and an out-of-stock variation for it.
+	 *
+	 * @return WC_Product_Variation
+	 */
+	private function create_out_of_stock_variation_with_fixed_and_any_attributes(): WC_Product_Variation {
+		$color = new WC_Product_Attribute();
+		$color->set_id( 0 );
+		$color->set_name( 'color' );
+		$color->set_options( array( 'red', 'blue' ) );
+		$color->set_visible( true );
+		$color->set_variation( true );
+
+		$finish = new WC_Product_Attribute();
+		$finish->set_id( 0 );
+		$finish->set_name( 'finish' );
+		$finish->set_options( array( 'gloss', 'matte' ) );
+		$finish->set_visible( true );
+		$finish->set_variation( true );
+
+		$product = new WC_Product_Variable();
+		$product->set_name( 'Variable Product' );
+		$product->set_attributes( array( $color, $finish ) );
+		$product->save();
+
+		$variation = new WC_Product_Variation();
+		$variation->set_parent_id( $product->get_id() );
+		$variation->set_attributes(
+			array(
+				'color'  => 'red',
+				'finish' => '',
+			)
+		);
+		$variation->set_regular_price( '10' );
+		$variation->set_stock_status( ProductStockStatus::OUT_OF_STOCK );
+		$variation->save();
+
+		return $variation;
+	}
+
+	/**
+	 * Create one active notification with the given posted attributes.
+	 *
+	 * @param int    $product_id The product ID.
+	 * @param string $email The user email.
+	 * @param array  $posted_attributes The posted attributes to store.
+	 * @return Notification
+	 */
+	private function create_active_notification_with_attributes( int $product_id, string $email, array $posted_attributes ): Notification {
+		$notification = new Notification();
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->set_product_id( $product_id );
+		$notification->set_user_email( $email );
+		$notification->update_meta_data( 'posted_attributes', $posted_attributes );
+		$notification->save();
+
+		return $notification;
+	}
+
+	/**
+	 * Create a variable product with a custom "any" attribute, and one out-of-stock variation for it.
+	 *
+	 * @param array $extra_options Additional values to declare for the attribute.
+	 * @return WC_Product_Variation
+	 */
+	private function create_out_of_stock_variation_with_any_attribute( array $extra_options = array() ): WC_Product_Variation {
+		$attribute = new WC_Product_Attribute();
+		$attribute->set_id( 0 );
+		$attribute->set_name( 'finish' );
+		$attribute->set_options( array_merge( array( 'gloss', 'matte' ), $extra_options ) );
+		$attribute->set_visible( true );
+		$attribute->set_variation( true );
+
+		$product = new WC_Product_Variable();
+		$product->set_name( 'Variable Product' );
+		$product->set_attributes( array( $attribute ) );
+		$product->save();
+
+		$variation = new WC_Product_Variation();
+		$variation->set_parent_id( $product->get_id() );
+		$variation->set_attributes( array( 'finish' => '' ) );
+		$variation->set_regular_price( '10' );
+		$variation->set_stock_status( ProductStockStatus::OUT_OF_STOCK );
+		$variation->save();
+
+		return $variation;
+	}
 }