Commit 48098ea283a for woocommerce

commit 48098ea283a3cbb638a65c0ee619925ebac558a2
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Fri Sep 11 17:37:50 2026 +0300

    Add a rate limiter to stock notification sign-ups (#68538)

    * feat: rate limit stock notification sign-ups per client and e-mail

    Claude-Session: https://claude.ai/code/session_01F36VTMqMfXgzYbo7Yf6dWb

    * fix: shorten the e-mail sign-up delay and switch the limiter off in e2e

    Claude-Session: https://claude.ai/code/session_01F36VTMqMfXgzYbo7Yf6dWb

    * fix: clear the failed rate-limit bucket when a sign-up claim rolls back

    Claude-Session: https://claude.ai/code/session_013bfbjMD6kwD5cubyNoPLbZ

    * fix: treat a WP_Error from Notification::save() as a sign-up failure

    Claude-Session: https://claude.ai/code/session_013bfbjMD6kwD5cubyNoPLbZ

    * feat: align the sign-up rate limiter options and hooks with the Store API limiter

    Claude-Session: https://claude.ai/code/session_01F36VTMqMfXgzYbo7Yf6dWb

    * fix: fail open when the sign-up rate limit window cannot be claimed

    * fix: render the sign-up rate limit message as a notice, not an error

    * fix: fall back to the default when a limiter option spells no boolean

diff --git a/plugins/woocommerce/changelog/wooplug-4996-stock-notifications-signup-rate-limiter b/plugins/woocommerce/changelog/wooplug-4996-stock-notifications-signup-rate-limiter
new file mode 100644
index 00000000000..b6e7e4ef42e
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-4996-stock-notifications-signup-rate-limiter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Rate limit stock notification sign-ups per client and e-mail address.
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/FormHandlerService.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/FormHandlerService.php
index 4bb3d80fe52..cdc20638079 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/FormHandlerService.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/FormHandlerService.php
@@ -81,7 +81,10 @@ class FormHandlerService {
 			);

 			if ( \is_wp_error( $result ) ) {
-				wc_add_notice( $this->signup_service->get_error_message( $result->get_error_code() ), 'error' );
+				// Match the resend cooldown, which asks the customer to wait rather than
+				// reporting a failure.
+				$notice_type = SignupService::ERROR_RATE_LIMITED === $result->get_error_code() ? 'notice' : 'error';
+				wc_add_notice( $this->signup_service->get_error_message( $result->get_error_code() ), $notice_type );
 				return;
 			}

diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupRateLimiter.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupRateLimiter.php
new file mode 100644
index 00000000000..11d5f6c9039
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupRateLimiter.php
@@ -0,0 +1,245 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\StockNotifications\Frontend;
+
+use WC_Geolocation;
+use WC_Rate_Limiter;
+
+/**
+ * Rate limits stock notification sign-up attempts.
+ *
+ * Sign-ups are throttled per client (the logged-in user, or the IP address for guests) and per
+ * e-mail address, so that neither a single client nor a single mailbox can be used to flood the
+ * store with sign-ups or verification e-mails. The two windows are independent, so a mailbox
+ * is still covered when the same address is used from several clients.
+ *
+ * @internal
+ */
+class SignupRateLimiter {
+
+	/**
+	 * Rate limit ID prefix for the IP address of the request.
+	 */
+	private const RATE_LIMIT_IP_PREFIX = 'stock_notifications_signup_ip_';
+
+	/**
+	 * Rate limit ID prefix for the logged-in user making the request.
+	 */
+	private const RATE_LIMIT_USER_PREFIX = 'stock_notifications_signup_user_';
+
+	/**
+	 * Rate limit ID prefix for the e-mail address used to sign up.
+	 */
+	private const RATE_LIMIT_EMAIL_PREFIX = 'stock_notifications_signup_email_';
+
+	/**
+	 * Rate limiting enabled default value.
+	 */
+	private const ENABLED = true;
+
+	/**
+	 * Proxy support enabled default value.
+	 */
+	private const PROXY_SUPPORT = false;
+
+	/**
+	 * Default number of seconds a client has to wait between two sign-up attempts.
+	 */
+	private const CLIENT_DELAY = MINUTE_IN_SECONDS / 2;
+
+	/**
+	 * Default number of seconds an e-mail address has to wait between two sign-up attempts.
+	 */
+	private const EMAIL_DELAY = MINUTE_IN_SECONDS / 2;
+
+	/**
+	 * Check whether the current sign-up attempt is rate limited.
+	 *
+	 * Fires `woocommerce_customer_stock_notifications_signup_rate_limit_exceeded` when it is.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $user_email The e-mail address used to sign up.
+	 * @return bool True if the attempt must be rejected.
+	 */
+	public function is_rate_limited( string $user_email ): bool {
+		foreach ( array_keys( $this->get_rate_limits( $user_email ) ) as $rate_limit_id ) {
+			if ( ! WC_Rate_Limiter::retried_too_soon( $rate_limit_id ) ) {
+				continue;
+			}
+
+			/**
+			 * Action: woocommerce_customer_stock_notifications_signup_rate_limit_exceeded
+			 *
+			 * Fires when a stock notification sign-up attempt is refused because the client
+			 * or the e-mail address retried too soon. Useful for tracking abuse.
+			 *
+			 * @since 11.2.0
+			 *
+			 * @param string $rate_limit_id The rate limit ID that was hit. Starts with
+			 *                              'stock_notifications_signup_user_', '..._ip_' or
+			 *                              '..._email_', followed by the user ID or a hash.
+			 * @param string $user_email    The e-mail address used to sign up.
+			 */
+			do_action( 'woocommerce_customer_stock_notifications_signup_rate_limit_exceeded', $rate_limit_id, $user_email );
+
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Start the rate limit windows for the current sign-up attempt.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string $user_email The e-mail address used to sign up.
+	 * @return bool True if every rate limit was applied.
+	 */
+	public function apply( string $user_email ): bool {
+		$applied_rate_limit_ids = array();
+
+		foreach ( $this->get_rate_limits( $user_email ) as $rate_limit_id => $delay ) {
+			if ( ! WC_Rate_Limiter::set_rate_limit( $rate_limit_id, $delay ) ) {
+				// Leave no partial window behind: a half-applied limit would block the
+				// customer on an attempt that never went through. The failed limit is
+				// cleared too, since set_rate_limit() caches the new expiry even when the
+				// write itself fails.
+				foreach ( array_merge( array( $rate_limit_id ), $applied_rate_limit_ids ) as $applied_rate_limit_id ) {
+					WC_Rate_Limiter::set_rate_limit( $applied_rate_limit_id, -1 );
+				}
+
+				return false;
+			}
+
+			$applied_rate_limit_ids[] = $rate_limit_id;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Get the rate limits that apply to the current sign-up attempt, keyed by rate limit ID.
+	 *
+	 * Buckets with a zero delay are left out, so switching one off writes nothing.
+	 *
+	 * @param string $user_email The e-mail address used to sign up.
+	 * @return array<string, int>
+	 */
+	private function get_rate_limits( string $user_email ): array {
+		$options     = $this->get_options();
+		$rate_limits = array();
+
+		if ( ! $options['enabled'] ) {
+			return $rate_limits;
+		}
+
+		if ( $options['client_delay'] > 0 ) {
+			if ( is_user_logged_in() ) {
+				$rate_limits[ self::RATE_LIMIT_USER_PREFIX . get_current_user_id() ] = $options['client_delay'];
+			} else {
+				$ip_address = $this->get_ip_address( $options['proxy_support'] );
+				if ( '' !== $ip_address ) {
+					$rate_limits[ self::RATE_LIMIT_IP_PREFIX . hash( 'sha256', $ip_address ) ] = $options['client_delay'];
+				}
+			}
+		}
+
+		$user_email = strtolower( trim( $user_email ) );
+		if ( '' !== $user_email && $options['email_delay'] > 0 ) {
+			$rate_limits[ self::RATE_LIMIT_EMAIL_PREFIX . hash( 'sha256', $user_email ) ] = $options['email_delay'];
+		}
+
+		return $rate_limits;
+	}
+
+	/**
+	 * Get the IP address to rate limit a guest request on.
+	 *
+	 * Only REMOTE_ADDR is trusted by default: forwarded headers are attacker-controlled
+	 * unless the store is actually behind a proxy that sets them. With proxy support on,
+	 * the address comes from the forwarding headers the rest of WooCommerce trusts.
+	 *
+	 * @param bool $proxy_support Whether to read the client address from forwarding headers.
+	 * @return string The IP address, or an empty string if it could not be resolved.
+	 */
+	private function get_ip_address( bool $proxy_support ): string {
+		if ( $proxy_support ) {
+			return WC_Geolocation::get_ip_address();
+		}
+
+		$ip_address = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- format-validated below via rest_is_ip_address().
+
+		return (string) rest_is_ip_address( $ip_address );
+	}
+
+	/**
+	 * Get the rate limiting options, with the filter applied and every value coerced to its type.
+	 *
+	 * @return array{enabled: bool, proxy_support: bool, client_delay: int, email_delay: int}
+	 */
+	private function get_options(): array {
+		$defaults = array(
+			'enabled'       => self::ENABLED,
+			'proxy_support' => self::PROXY_SUPPORT,
+			'client_delay'  => (int) self::CLIENT_DELAY,
+			'email_delay'   => (int) self::EMAIL_DELAY,
+		);
+
+		/**
+		 * Filter: woocommerce_customer_stock_notifications_signup_rate_limit_options
+		 *
+		 * Options for rate limiting stock notification sign-ups. Mirrors the shape of
+		 * `woocommerce_store_api_rate_limit_options`.
+		 *
+		 * - `enabled`: switches the limiter off entirely. Default true.
+		 * - `proxy_support`: read the client address from forwarding headers (X-Real-IP,
+		 *   X-Forwarded-For) rather than REMOTE_ADDR. Enable only when the store is behind a
+		 *   proxy, load balancer or CDN that sets them, since the headers are otherwise
+		 *   spoofable. Default false.
+		 * - `client_delay`: seconds a client (the logged-in user, or the IP address for
+		 *   guests) has to wait between two sign-up attempts. 0 switches this limit off.
+		 *   Default 30.
+		 * - `email_delay`: seconds an e-mail address has to wait between two sign-up attempts.
+		 *   0 switches this limit off. Default 30.
+		 *
+		 * @since 11.2.0
+		 *
+		 * @param array $options Rate limiting options.
+		 */
+		$options = apply_filters( 'woocommerce_customer_stock_notifications_signup_rate_limit_options', $defaults );
+
+		if ( ! is_array( $options ) ) {
+			$options = $defaults;
+		}
+
+		// A callback can return anything: unknown or non-numeric values fall back to the
+		// default, and a negative delay would clear the limit instead of setting one, so it
+		// is clamped to zero.
+		return array(
+			'enabled'       => $this->to_bool( $options['enabled'] ?? $defaults['enabled'], $defaults['enabled'] ),
+			'proxy_support' => $this->to_bool( $options['proxy_support'] ?? $defaults['proxy_support'], $defaults['proxy_support'] ),
+			'client_delay'  => is_numeric( $options['client_delay'] ?? null ) ? max( 0, (int) $options['client_delay'] ) : $defaults['client_delay'],
+			'email_delay'   => is_numeric( $options['email_delay'] ?? null ) ? max( 0, (int) $options['email_delay'] ) : $defaults['email_delay'],
+		);
+	}
+
+	/**
+	 * Coerce a filtered option to a boolean, accepting the usual 'yes'/'true'/1 spellings.
+	 *
+	 * @param mixed $value    The filtered value.
+	 * @param bool  $fallback The value to use when the filtered value spells no boolean.
+	 */
+	private function to_bool( $value, bool $fallback ): bool {
+		if ( is_bool( $value ) ) {
+			return $value;
+		}
+
+		$filtered = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
+
+		return null === $filtered ? $fallback : $filtered;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
index 81c58b92fc1..1949983ac32 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
@@ -59,6 +59,20 @@ class SignupService {
 	 */
 	private EmailManager $email_manager;

+	/**
+	 * Signup rate limiter.
+	 *
+	 * @var SignupRateLimiter
+	 */
+	private SignupRateLimiter $rate_limiter;
+
+	/**
+	 * The logger.
+	 *
+	 * @var \WC_Logger_Interface
+	 */
+	private $logger;
+
 	/**
 	 * Init the service.
 	 *
@@ -67,20 +81,28 @@ class SignupService {
 	 * @param EligibilityService            $eligibility_service The eligibility service.
 	 * @param NotificationManagementService $notification_management_service The notification management service.
 	 * @param EmailManager                  $email_manager The email manager.
+	 * @param SignupRateLimiter             $rate_limiter The signup rate limiter.
 	 */
 	final public function init(
 		EligibilityService $eligibility_service,
 		NotificationManagementService $notification_management_service,
-		EmailManager $email_manager
+		EmailManager $email_manager,
+		SignupRateLimiter $rate_limiter
 	) {
 		$this->eligibility_service             = $eligibility_service;
 		$this->notification_management_service = $notification_management_service;
 		$this->email_manager                   = $email_manager;
+		$this->rate_limiter                    = $rate_limiter;
+		$this->logger                          = \wc_get_logger();
 	}

 	/**
 	 * Signup.
 	 *
+	 * Fail-closed: once the rate limit window is claimed it is not released, so a failure or an
+	 * 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.
+	 *
 	 * @param int    $product_id The product ID.
 	 * @param int    $user_id The user ID.
 	 * @param string $user_email The user email.
@@ -117,6 +139,9 @@ 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.
 		$notification = $this->is_already_signed_up( $product_id, $user_id, $user_email, $posted_attributes );
 		if ( $notification instanceof Notification ) {
 			if ( NotificationStatus::ACTIVE === $notification->get_status() ) {
@@ -128,9 +153,12 @@ class SignupService {
 					return new SignupResult( self::SIGNUP_ALREADY_JOINED_DOUBLE_OPT_IN, $notification );
 				}

-				// If the notification is pending and double opt-in is not required, skip and activate the notification.
+				// Double opt-in is not required, so activate the pending notification instead of creating one.
 				$notification->set_status( NotificationStatus::ACTIVE );
-				$notification->save();
+				$saved = $notification->save();
+				if ( \is_wp_error( $saved ) || ! $saved ) {
+					return new \WP_Error( self::ERROR_FAILED );
+				}

 				/**
 				 * Action: woocommerce_customer_stock_notifications_signup
@@ -144,6 +172,24 @@ class SignupService {
 			}
 		}

+		if ( $this->rate_limiter->is_rate_limited( $user_email ) ) {
+			return new \WP_Error( self::ERROR_RATE_LIMITED );
+		}
+
+		// Claim the rate limit window before creating an account, storing a notification or
+		// sending mail. This narrows the window in which two near-simultaneous requests both
+		// get through; it does not close it.
+		//
+		// A claim only fails when the rate limit table cannot be written to, which a shopper
+		// can neither cause nor resolve. Let the sign-up through rather than turn a broken
+		// limiter into a store-wide sign-up outage.
+		if ( ! $this->rate_limiter->apply( $user_email ) ) {
+			$this->logger->warning(
+				'Could not claim the stock notification sign-up rate limit window. Allowing the sign-up to proceed.',
+				array( 'source' => 'stock-notifications-signup-errors' )
+			);
+		}
+
 		$account_created = null;
 		if ( empty( $user_id ) && Config::creates_account_on_signup() ) {
 			$account_created = $this->create_customer( $user_email );
@@ -165,7 +211,7 @@ class SignupService {
 		}

 		$saved = $notification->save();
-		if ( ! $saved ) {
+		if ( \is_wp_error( $saved ) || ! $saved ) {
 			return new \WP_Error( self::ERROR_FAILED );
 		}

@@ -479,7 +525,7 @@ class SignupService {
 			case self::ERROR_INVALID_OPT_IN:
 				return wp_kses_post( __( 'To proceed, please consent to the creation of a new account with your e-mail.', 'woocommerce' ) );
 			case self::ERROR_RATE_LIMITED:
-				return wp_kses_post( __( 'You have already signed up too many times. Please try again later.', 'woocommerce' ) );
+				return wp_kses_post( __( 'Please wait a moment before signing up again.', 'woocommerce' ) );
 			default:
 				return wp_kses_post( __( 'Failed to sign up. Please try again.', 'woocommerce' ) );
 		}
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md
index cc75b8b122a..720a9f09aa3 100644
--- a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md
@@ -53,6 +53,11 @@ Covers the scenarios from the original plugin test plan that have a target in co
   not an option, so `expireVerificationLinks()` sets it to a negative value
   through the same cookie. Both tests clear the cookie afterwards with
   `clearFilters()`.
+- Sign-ups are rate limited per client and per e-mail
+  (`woocommerce_customer_stock_notifications_signup_rate_limit_options`), and
+  the suite submits the form far more often than a shopper would from one
+  customer and one IP. `signUpOnProductPage()` disables the limiter through the
+  same cookie before every submit, so no spec has to opt in.
 - The email templates fork on `$is_guest`, which is "the signup has no
   `WP_User`", not "the shopper was logged out": a guest signup with an email
   that already belongs to an account, or one that created an account on signup,
diff --git a/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts b/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
index f3ef921f239..1b0d395f796 100644
--- a/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
+++ b/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
@@ -500,9 +500,30 @@ export function bisConsentCheckbox( page: Page ) {
 	return page.locator( 'input[name="wc_bis_opt_in"]' );
 }

+/**
+ * Switch the sign-up rate limiter off for this page's context.
+ *
+ * Core locks a client and an e-mail address out for a while after each
+ * sign-up. The suite submits the form far more often than that from one
+ * customer and one IP, so it is disabled through the `e2e-filters`
+ * cookie the test helper plugin reads.
+ *
+ * @param {Page} page Playwright page whose context will submit the form.
+ */
+export async function disableSignupRateLimit( page: Page ): Promise< void > {
+	await setFilterValue(
+		page,
+		'woocommerce_customer_stock_notifications_signup_rate_limit_options',
+		{ enabled: false }
+	);
+}
+
 /**
  * Submit the PDP sign-up form. Caller must already have the product page loaded.
  *
+ * The rate limiter is switched off for the submitting context first, so
+ * back-to-back sign-ups within a spec are not refused.
+ *
  * @param {Page}    page           Playwright page on the product detail.
  * @param {Object}  [opts]         Fill options.
  * @param {string}  [opts.email]   Email address to enter (guest flow only; logged-in PDP hides the field).
@@ -515,6 +536,8 @@ export async function signUpOnProductPage(
 		consent?: boolean;
 	} = {}
 ): Promise< void > {
+	await disableSignupRateLimit( page );
+
 	if ( opts.email !== undefined ) {
 		await page
 			.getByRole( 'textbox', {
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupRateLimiterTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupRateLimiterTests.php
new file mode 100644
index 00000000000..7c985b9c745
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupRateLimiterTests.php
@@ -0,0 +1,473 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Frontend;
+
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\SignupRateLimiter;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the SignupRateLimiter class.
+ */
+class SignupRateLimiterTests extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var SignupRateLimiter
+	 */
+	private $sut;
+
+	/**
+	 * The server values seen before the test replaced them.
+	 *
+	 * @var array<string, string|null>
+	 */
+	private $original_server = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		foreach ( array( 'REMOTE_ADDR', 'HTTP_X_FORWARDED_FOR' ) as $key ) {
+			$this->original_server[ $key ] = isset( $_SERVER[ $key ] ) ? sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) : null;
+		}
+
+		$_SERVER['REMOTE_ADDR'] = '192.0.2.10';
+		unset( $_SERVER['HTTP_X_FORWARDED_FOR'] );
+
+		$this->sut = new SignupRateLimiter();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		try {
+			foreach ( $this->original_server as $key => $value ) {
+				if ( null === $value ) {
+					unset( $_SERVER[ $key ] );
+				} else {
+					$_SERVER[ $key ] = $value;
+				}
+			}
+
+			wp_set_current_user( 0 );
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * @testdox Should not rate limit the first attempt.
+	 */
+	public function test_first_attempt_is_not_rate_limited(): void {
+		$this->assertFalse( $this->sut->is_rate_limited( 'shopper@example.com' ), 'A first sign-up attempt should go through' );
+	}
+
+	/**
+	 * @testdox Should rate limit a repeated attempt from the same e-mail address.
+	 */
+	public function test_repeated_attempt_is_rate_limited(): void {
+		$this->assertTrue( $this->sut->apply( 'shopper@example.com' ), 'The rate limit should be applied' );
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'shopper@example.com' ), 'A repeated sign-up attempt should be rate limited' );
+	}
+
+	/**
+	 * @testdox Should treat e-mail addresses that differ only in case and whitespace as the same.
+	 */
+	public function test_email_is_normalized(): void {
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertTrue( $this->sut->is_rate_limited( '  SHOPPER@Example.com ' ), 'The e-mail address should be normalized before hashing' );
+	}
+
+	/**
+	 * @testdox Should rate limit another e-mail address coming from the same IP address.
+	 */
+	public function test_other_email_from_same_ip_is_rate_limited(): void {
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'other@example.com' ), 'Sign-ups should also be rate limited per IP address' );
+	}
+
+	/**
+	 * @testdox Should not rate limit another e-mail address coming from another IP address.
+	 */
+	public function test_other_email_from_other_ip_is_not_rate_limited(): void {
+		$this->sut->apply( 'shopper@example.com' );
+
+		$_SERVER['REMOTE_ADDR'] = '192.0.2.20';
+
+		$this->assertFalse( $this->sut->is_rate_limited( 'other@example.com' ), 'A different client signing up with a different e-mail address should not be held back' );
+	}
+
+	/**
+	 * @testdox Should keep rate limiting the e-mail address after the IP limit is switched off.
+	 */
+	public function test_email_limit_is_independent_of_the_client_limit(): void {
+		$this->set_delays( 0, 600 );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$_SERVER['REMOTE_ADDR'] = '192.0.2.20';
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'shopper@example.com' ), 'The same e-mail address should be rate limited from any IP address' );
+		$this->assertFalse( $this->sut->is_rate_limited( 'other@example.com' ), 'With the per-IP limit off, another e-mail address should go through' );
+	}
+
+	/**
+	 * @testdox Should keep rate limiting the IP address after the e-mail limit is switched off.
+	 */
+	public function test_client_limit_is_independent_of_the_email_limit(): void {
+		$this->set_delays( 30, 0 );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'other@example.com' ), 'The same client should be rate limited whichever e-mail address it uses' );
+
+		$_SERVER['REMOTE_ADDR'] = '192.0.2.20';
+
+		$this->assertFalse( $this->sut->is_rate_limited( 'shopper@example.com' ), 'With the per-e-mail limit off, another client should go through' );
+	}
+
+	/**
+	 * @testdox Should switch rate limiting off when both delays are zero, without writing anything.
+	 */
+	public function test_zero_delays_disable_rate_limiting_and_write_nothing(): void {
+		$this->set_delays( 0, 0 );
+
+		$queries = $this->record_queries(
+			function () {
+				$this->assertTrue( $this->sut->apply( 'shopper@example.com' ), 'A disabled limiter should report success' );
+				$this->assertFalse( $this->sut->is_rate_limited( 'shopper@example.com' ), 'A zero delay should let every attempt through' );
+			}
+		);
+
+		foreach ( $queries as $query ) {
+			$this->assertStringNotContainsString( 'wc_rate_limits', $query, 'A disabled limiter should not touch the rate limits table' );
+		}
+	}
+
+	/**
+	 * @testdox Should apply delays that the filter returns as numeric strings.
+	 */
+	public function test_numeric_string_delays_are_applied(): void {
+		$this->set_delays( '90', '90' );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'shopper@example.com' ), 'A numeric string delay should be coerced to an integer and applied' );
+	}
+
+	/**
+	 * @testdox Should ignore forwarded headers by default.
+	 */
+	public function test_forwarded_header_is_ignored_by_default(): void {
+		$_SERVER['HTTP_X_FORWARDED_FOR'] = '198.51.100.5';
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$_SERVER['REMOTE_ADDR'] = '192.0.2.20';
+
+		$this->assertFalse( $this->sut->is_rate_limited( 'other@example.com' ), 'A spoofable header should not be used to key the rate limit' );
+	}
+
+	/**
+	 * @testdox Should use the forwarded header when the store enables proxy support.
+	 */
+	public function test_forwarded_header_is_used_with_proxy_support(): void {
+		$_SERVER['HTTP_X_FORWARDED_FOR'] = '198.51.100.5';
+
+		$this->set_options( array( 'proxy_support' => true ) );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$_SERVER['REMOTE_ADDR'] = '192.0.2.20';
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'other@example.com' ), 'A store behind a proxy should key the rate limit on the forwarded address' );
+
+		$_SERVER['HTTP_X_FORWARDED_FOR'] = '198.51.100.6';
+
+		$this->assertFalse( $this->sut->is_rate_limited( 'guest@example.com' ), 'Another forwarded address should not be held back' );
+	}
+
+	/**
+	 * @testdox Should switch the per-IP limit off when the forwarded address is not an IP address.
+	 */
+	public function test_invalid_forwarded_address_disables_the_ip_limit(): void {
+		$_SERVER['HTTP_X_FORWARDED_FOR'] = 'not-an-ip';
+
+		$this->set_options( array( 'proxy_support' => true ) );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertFalse( $this->sut->is_rate_limited( 'other@example.com' ), 'An unresolved IP address should not be rate limited' );
+		$this->assertTrue( $this->sut->is_rate_limited( 'shopper@example.com' ), 'The per-e-mail limit should still apply' );
+	}
+
+	/**
+	 * @testdox Should switch rate limiting off when disabled through the options, without writing anything.
+	 */
+	public function test_disabled_option_switches_rate_limiting_off(): void {
+		$this->set_options( array( 'enabled' => false ) );
+
+		$queries = $this->record_queries(
+			function () {
+				$this->assertTrue( $this->sut->apply( 'shopper@example.com' ), 'A disabled limiter should report success' );
+				$this->assertFalse( $this->sut->is_rate_limited( 'shopper@example.com' ), 'A disabled limiter should let every attempt through' );
+			}
+		);
+
+		foreach ( $queries as $query ) {
+			$this->assertStringNotContainsString( 'wc_rate_limits', $query, 'A disabled limiter should not touch the rate limits table' );
+		}
+	}
+
+	/**
+	 * @testdox Should keep rate limiting when the enabled option spells no recognized boolean.
+	 */
+	public function test_unrecognized_enabled_option_falls_back_to_the_default(): void {
+		$this->set_options( array( 'enabled' => 'ture' ) );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'shopper@example.com' ), 'A value that spells no boolean should fall back to the default rather than switch the limiter off' );
+	}
+
+	/**
+	 * @testdox Should fire an action naming the rate limit that was hit.
+	 */
+	public function test_exceeded_action_fires_with_the_rate_limit_id(): void {
+		$this->set_delays( 0, 30 );
+
+		$fired = array();
+		add_action(
+			'woocommerce_customer_stock_notifications_signup_rate_limit_exceeded',
+			static function ( $rate_limit_id, $user_email ) use ( &$fired ) {
+				$fired[] = array( $rate_limit_id, $user_email );
+			},
+			10,
+			2
+		);
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertFalse( $this->sut->is_rate_limited( 'other@example.com' ) );
+		$this->assertSame( array(), $fired, 'The action should not fire for an attempt that goes through' );
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'shopper@example.com' ) );
+		$this->assertCount( 1, $fired, 'The action should fire once for a refused attempt' );
+		$this->assertStringStartsWith( 'stock_notifications_signup_email_', $fired[0][0], 'The action should receive the ID of the limit that was hit' );
+		$this->assertSame( 'shopper@example.com', $fired[0][1], 'The action should receive the e-mail address' );
+	}
+
+	/**
+	 * @testdox Should key a logged-in shopper on their user ID rather than their IP address.
+	 */
+	public function test_logged_in_user_is_keyed_on_user_id_not_ip(): void {
+		wp_set_current_user( $this->factory->user->create() );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$_SERVER['REMOTE_ADDR'] = '192.0.2.20';
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'other@example.com' ), 'The same logged-in user should be rate limited even from another IP address' );
+
+		wp_set_current_user( 0 );
+
+		$this->assertFalse( $this->sut->is_rate_limited( 'guest@example.com' ), 'A guest on that IP address should not be held back by the logged-in user limit' );
+	}
+
+	/**
+	 * @testdox Should not rate limit another logged-in user coming from the same IP address.
+	 */
+	public function test_other_logged_in_user_from_same_ip_is_not_rate_limited(): void {
+		wp_set_current_user( $this->factory->user->create() );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		wp_set_current_user( $this->factory->user->create() );
+
+		$this->assertFalse( $this->sut->is_rate_limited( 'other@example.com' ), 'A different logged-in user on the same IP address should not be held back' );
+	}
+
+	/**
+	 * @testdox Should still rate limit a logged-in user when the client IP address is invalid.
+	 */
+	public function test_invalid_ip_does_not_disable_the_limit_for_logged_in_user(): void {
+		unset( $_SERVER['REMOTE_ADDR'] );
+
+		$this->set_delays( 30, 0 );
+
+		wp_set_current_user( $this->factory->user->create() );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'other@example.com' ), 'A logged-in user should be rate limited regardless of whether the IP address resolves' );
+	}
+
+	/**
+	 * @testdox Should roll back the limits it already stored when one of them cannot be stored.
+	 */
+	public function test_apply_rolls_back_when_a_limit_cannot_be_stored(): void {
+		global $wpdb;
+
+		$suppress = $wpdb->suppress_errors( true );
+		$filter   = static function ( $query ) {
+			if ( false !== strpos( $query, 'stock_notifications_signup_email_' ) ) {
+				return 'SELECT 1 FROM a_table_that_does_not_exist';
+			}
+
+			return $query;
+		};
+
+		add_filter( 'query', $filter );
+
+		try {
+			$applied = $this->sut->apply( 'shopper@example.com' );
+		} finally {
+			$wpdb->suppress_errors( $suppress );
+		}
+
+		remove_filter( 'query', $filter );
+
+		$this->assertFalse( $applied, 'A sign-up attempt whose limits cannot be stored should not be let through' );
+		$this->assertFalse( $this->sut->is_rate_limited( 'other@example.com' ), 'The per-IP limit should have been rolled back' );
+		$this->assertFalse( $this->sut->is_rate_limited( 'shopper@example.com' ), 'The limit that could not be stored should have been cleared' );
+	}
+
+	/**
+	 * @testdox Should fall back to the default options when the filter does not return an array.
+	 */
+	public function test_non_array_options_fall_back_to_defaults(): void {
+		$this->set_options( 'nope' );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'shopper@example.com' ), 'Non-array options should fall back to the defaults, which still rate limit' );
+	}
+
+	/**
+	 * @testdox Should switch rate limiting off when both delays are negative, without writing anything.
+	 */
+	public function test_negative_delays_disable_rate_limiting(): void {
+		$this->set_delays( -30, -30 );
+
+		$queries = $this->record_queries(
+			function () {
+				$this->assertTrue( $this->sut->apply( 'shopper@example.com' ), 'A disabled limiter should report success' );
+				$this->assertFalse( $this->sut->is_rate_limited( 'shopper@example.com' ), 'A negative delay should let every attempt through' );
+			}
+		);
+
+		foreach ( $queries as $query ) {
+			$this->assertStringNotContainsString( 'wc_rate_limits', $query, 'A disabled limiter should not touch the rate limits table' );
+		}
+	}
+
+	/**
+	 * @testdox Should fall back to the default delays when the filter returns non-numeric values.
+	 */
+	public function test_non_numeric_delays_fall_back_to_defaults(): void {
+		$this->set_delays( false, array( 30 ) );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'shopper@example.com' ), 'Non-numeric delays should fall back to the defaults, which still rate limit' );
+	}
+
+	/**
+	 * @testdox Should not fatal when the filter returns an object as a delay.
+	 */
+	public function test_object_delay_does_not_fatal(): void {
+		$this->set_delays( new \stdClass(), new \stdClass() );
+
+		$this->assertTrue( $this->sut->apply( 'shopper@example.com' ), 'An object delay should not cause a fatal error' );
+	}
+
+	/**
+	 * @testdox Should disable the per-client limit for guests when the client address cannot be resolved.
+	 */
+	public function test_missing_remote_addr_disables_the_client_limit_for_guests(): void {
+		unset( $_SERVER['REMOTE_ADDR'] );
+		wp_set_current_user( 0 );
+
+		$this->set_delays( 30, 0 );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertFalse( $this->sut->is_rate_limited( 'other@example.com' ), 'With no resolvable client address, the per-client limit should not apply' );
+	}
+
+	/**
+	 * @testdox Should keep rate limiting the e-mail address when the client address cannot be resolved.
+	 */
+	public function test_missing_remote_addr_still_rate_limits_the_email(): void {
+		unset( $_SERVER['REMOTE_ADDR'] );
+		wp_set_current_user( 0 );
+
+		$this->set_delays( 30, 180 );
+
+		$this->sut->apply( 'shopper@example.com' );
+
+		$this->assertTrue( $this->sut->is_rate_limited( 'shopper@example.com' ), 'The per-e-mail limit should still apply when the client address cannot be resolved' );
+	}
+
+	/**
+	 * Filter the delays used by the limiter.
+	 *
+	 * @param mixed $client Delay for the per-client limit.
+	 * @param mixed $email  Delay for the per-e-mail limit.
+	 */
+	private function set_delays( $client, $email ): void {
+		$this->set_options(
+			array(
+				'client_delay' => $client,
+				'email_delay'  => $email,
+			)
+		);
+	}
+
+	/**
+	 * Filter the options used by the limiter. Keys left out keep their default.
+	 *
+	 * @param mixed $options The options to return from the filter.
+	 */
+	private function set_options( $options ): void {
+		add_filter(
+			'woocommerce_customer_stock_notifications_signup_rate_limit_options',
+			static function ( $defaults ) use ( $options ) {
+				return is_array( $options ) ? array_merge( $defaults, $options ) : $options;
+			}
+		);
+	}
+
+	/**
+	 * Run a callback and return every database query it made.
+	 *
+	 * @param callable $callback The callback to run.
+	 * @return string[]
+	 */
+	private function record_queries( callable $callback ): array {
+		$queries = array();
+		$filter  = static function ( $query ) use ( &$queries ) {
+			$queries[] = $query;
+			return $query;
+		};
+
+		add_filter( 'query', $filter );
+
+		try {
+			$callback();
+		} finally {
+			remove_filter( 'query', $filter );
+		}
+
+		return $queries;
+	}
+}
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 a465aeae312..1c0cf626be5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Frontend;
 use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\NotificationManagementService;
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\SignupRateLimiter;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\SignupService;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService;
@@ -34,6 +35,13 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
 	 */
 	private $email_manager;

+	/**
+	 * The remote address seen before the test replaced it.
+	 *
+	 * @var string|null
+	 */
+	private $original_remote_addr;
+
 	/**
 	 * Set up test fixtures.
 	 */
@@ -41,6 +49,9 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
 		parent::setUp();
 		$this->enable_stock_notifications_feature();

+		$this->original_remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : null;
+		$_SERVER['REMOTE_ADDR']     = '192.0.2.10';
+
 		update_option( 'woocommerce_customer_stock_notifications_allow_signups', 'yes' );

 		$eligibility_service = new EligibilityService();
@@ -52,13 +63,19 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
 		$notification_management_service->init( $this->email_manager );

 		$this->sut = new SignupService();
-		$this->sut->init( $eligibility_service, $notification_management_service, $this->email_manager );
+		$this->sut->init( $eligibility_service, $notification_management_service, $this->email_manager, new SignupRateLimiter() );
 	}

 	/**
 	 * Tear down test fixtures.
 	 */
 	public function tearDown(): void {
+		if ( null === $this->original_remote_addr ) {
+			unset( $_SERVER['REMOTE_ADDR'] );
+		} else {
+			$_SERVER['REMOTE_ADDR'] = $this->original_remote_addr;
+		}
+
 		delete_option( 'woocommerce_customer_stock_notifications_allow_signups' );
 		delete_option( 'woocommerce_customer_stock_notifications_require_double_opt_in' );

@@ -110,6 +127,176 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
 		$this->sut->signup( $product->get_id(), 0, 'guest@example.com' );
 	}

+	/**
+	 * @testdox Should reject a second signup made within the rate limit window.
+	 */
+	public function test_second_signup_is_rate_limited() {
+		$product       = $this->create_out_of_stock_product();
+		$other_product = $this->create_out_of_stock_product();
+
+		$this->sut->signup( $product->get_id(), 0, 'guest@example.com' );
+		$result = $this->sut->signup( $other_product->get_id(), 0, 'guest@example.com' );
+
+		$this->assertWPError( $result, 'A signup within the rate limit window should fail' );
+		$this->assertEquals( SignupService::ERROR_RATE_LIMITED, $result->get_error_code(), 'The failure should be reported as rate limited' );
+	}
+
+	/**
+	 * @testdox Should not create a notification or send an email for a rate limited signup.
+	 */
+	public function test_rate_limited_signup_creates_nothing() {
+		update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'yes' );
+
+		$product       = $this->create_out_of_stock_product();
+		$other_product = $this->create_out_of_stock_product();
+
+		$this->sut->signup( $product->get_id(), 0, 'guest@example.com' );
+
+		$this->email_manager
+			->expects( $this->never() )
+			->method( 'send_verify_email' );
+
+		$result = $this->sut->signup( $other_product->get_id(), 0, 'guest@example.com' );
+
+		$this->assertWPError( $result, 'A signup within the rate limit window should fail' );
+		$this->assertNull( $this->sut->is_already_signed_up( $other_product->get_id(), 0, 'guest@example.com' ), 'A rate limited signup should not have created a notification' );
+	}
+
+	/**
+	 * @testdox Should rate limit a logged-in customer on the account email address.
+	 */
+	public function test_logged_in_signup_is_rate_limited_on_the_account_email() {
+		add_filter(
+			'woocommerce_customer_stock_notifications_signup_rate_limit_options',
+			static function () {
+				return array(
+					'client_delay' => 0,
+					'email_delay'  => 600,
+				);
+			}
+		);
+
+		$user_id       = wp_insert_user(
+			array(
+				'user_login' => 'stock_notifications_shopper',
+				'user_pass'  => wp_generate_password(),
+				'user_email' => 'shopper@example.com',
+			)
+		);
+		$product       = $this->create_out_of_stock_product();
+		$other_product = $this->create_out_of_stock_product();
+
+		$this->sut->signup( $product->get_id(), $user_id, 'shopper@example.com' );
+		$result = $this->sut->signup( $other_product->get_id(), $user_id, 'shopper@example.com' );
+
+		$this->assertWPError( $result, 'A second signup from the same account should fail' );
+		$this->assertEquals( SignupService::ERROR_RATE_LIMITED, $result->get_error_code(), 'The failure should be reported as rate limited' );
+	}
+
+	/**
+	 * @testdox Should not consume the rate limit window when the customer had already joined the waitlist.
+	 */
+	public function test_already_joined_does_not_consume_the_rate_limit_window() {
+		$product       = $this->create_out_of_stock_product();
+		$other_product = $this->create_out_of_stock_product();
+
+		$notification = new Notification();
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->set_product_id( $product->get_id() );
+		$notification->set_user_email( 'guest@example.com' );
+		$notification->save();
+
+		$already_joined = $this->sut->signup( $product->get_id(), 0, 'guest@example.com' );
+		$this->assertEquals( SignupService::SIGNUP_ALREADY_JOINED, $already_joined->get_code(), 'The signup should report that the waitlist was already joined' );
+
+		$result = $this->sut->signup( $other_product->get_id(), 0, 'guest@example.com' );
+
+		$this->assertNotWPError( $result, 'An attempt that only found an existing signup should not consume the rate limit window' );
+	}
+
+	/**
+	 * @testdox Should not consume the rate limit window when a pending double opt-in signup is already awaiting confirmation.
+	 */
+	public function test_pending_double_opt_in_signup_does_not_consume_the_rate_limit_window() {
+		update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'yes' );
+
+		$product       = $this->create_out_of_stock_product();
+		$other_product = $this->create_out_of_stock_product();
+
+		$notification = new Notification();
+		$notification->set_status( NotificationStatus::PENDING );
+		$notification->set_product_id( $product->get_id() );
+		$notification->set_user_email( 'guest@example.com' );
+		$notification->save();
+
+		$result = $this->sut->signup( $product->get_id(), 0, 'guest@example.com' );
+		$this->assertEquals( SignupService::SIGNUP_ALREADY_JOINED_DOUBLE_OPT_IN, $result->get_code(), 'The signup should report that the waitlist was already joined pending confirmation' );
+
+		$second = $this->sut->signup( $other_product->get_id(), 0, 'guest@example.com' );
+		$this->assertNotWPError( $second, 'A pending double opt-in signup should not consume the rate limit window' );
+	}
+
+	/**
+	 * @testdox Should activate a pending notification without consuming the rate limit window when double opt-in is disabled.
+	 */
+	public function test_pending_signup_is_activated_and_does_not_consume_the_rate_limit_window_when_double_opt_in_disabled() {
+		update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
+
+		$product       = $this->create_out_of_stock_product();
+		$other_product = $this->create_out_of_stock_product();
+
+		$notification = new Notification();
+		$notification->set_status( NotificationStatus::PENDING );
+		$notification->set_product_id( $product->get_id() );
+		$notification->set_user_email( 'guest@example.com' );
+		$notification->save();
+
+		$signup_fired_count = 0;
+		add_action(
+			'woocommerce_customer_stock_notifications_signup',
+			static function () use ( &$signup_fired_count ) {
+				++$signup_fired_count;
+			}
+		);
+
+		$result = $this->sut->signup( $product->get_id(), 0, 'guest@example.com' );
+		$this->assertNotWPError( $result, 'Activating an existing pending signup should succeed' );
+		$this->assertEquals( SignupService::SIGNUP_SUCCESS, $result->get_code(), 'The signup should report success' );
+		$this->assertEquals( 1, $signup_fired_count, 'The signup action should have fired exactly once' );
+
+		$reloaded = $this->sut->is_already_signed_up( $product->get_id(), 0, 'guest@example.com' );
+		$this->assertInstanceOf( Notification::class, $reloaded, 'The notification should still exist' );
+		$this->assertEquals( NotificationStatus::ACTIVE, $reloaded->get_status(), 'The notification should now be active' );
+
+		$second = $this->sut->signup( $other_product->get_id(), 0, 'guest@example.com' );
+		$this->assertNotWPError( $second, 'Activating an existing pending signup should not consume the rate limit window' );
+	}
+
+	/**
+	 * @testdox Should let the signup through when the rate limit window cannot be claimed.
+	 */
+	public function test_signup_proceeds_when_the_rate_limit_cannot_be_claimed() {
+		$rate_limiter = $this->createMock( SignupRateLimiter::class );
+		$rate_limiter->method( 'is_rate_limited' )->willReturn( false );
+		$rate_limiter->method( 'apply' )->willReturn( false );
+
+		$eligibility_service = new EligibilityService();
+		$eligibility_service->init( new StockManagementHelper() );
+
+		$notification_management_service = new NotificationManagementService();
+		$notification_management_service->init( $this->email_manager );
+
+		$sut = new SignupService();
+		$sut->init( $eligibility_service, $notification_management_service, $this->email_manager, $rate_limiter );
+
+		$product = $this->create_out_of_stock_product();
+		$result  = $sut->signup( $product->get_id(), 0, 'guest@example.com' );
+
+		$this->assertNotWPError( $result, 'A signup should not fail because the rate limit window could not be claimed' );
+		$this->assertEquals( SignupService::SIGNUP_SUCCESS, $result->get_code(), 'The signup should report success' );
+		$this->assertInstanceOf( Notification::class, $sut->is_already_signed_up( $product->get_id(), 0, 'guest@example.com' ), 'The notification should have been created' );
+	}
+
 	/**
 	 * Create an out-of-stock simple product for signup.
 	 *