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.
*