Commit 764a6cc00c4 for woocommerce
commit 764a6cc00c490748623e02a0ea80d49655d87aee
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date: Mon Oct 19 14:52:13 2026 +0300
Show a notice when a back-in-stock verification or unsubscribe link is no longer valid (#69827)
* fix: keep sent BIS notifications from being cancelled on unsubscribe
* test: cover cancelling a pending notification via unsubscribe link
* test: expect sent notifications to stay sent after the unsubscribe link in e2e
The back-in-stock email's unsubscribe link no longer cancels a sent notification, so the e2e tests now expect the already-sent notice and a Sent row. The tampered-key test's positive control checks for the same notice.
* style: fix Prettier indentation in back-in-stock e2e utils
* test: match the already-sent back-in-stock notice exactly
Store the notice as a plain string and pass { exact: true } to each
getByText call so text with extra content appended no longer matches.
* fix: show a notice when a back-in-stock email link is no longer valid
A verification or unsubscribe link with an expired, superseded or
tampered key, or one for a sign-up that is no longer pending, returned
silently and left the shopper on the homepage with no feedback. The
email action handlers now redirect to the same destination as the
success path with a notice that explains why the link did nothing.
* style: fix Prettier indentation in back-in-stock e2e utils
* fix: use tabs for ternary array indentation in BIS e2e utils
* fix: report verified sign-ups and deleted notifications accurately on stale BIS links
Check the verification key hash before expiry so an already-verified
sign-up whose link has aged past the threshold shows the verified notice
instead of telling the shopper to sign up again. Mismatched keys still get
the generic notice regardless of status.
A verify or unsubscribe link whose notification no longer exists now
redirects with the same generic invalid-link notice instead of dropping
the shopper on the homepage silently.
* fix: fall back to the home URL when no Shop page is set
Use wc_get_page_permalink( 'shop' ) in redirect_with_notice() so stores
without a Shop page redirect email action links to the home URL instead
of passing false to wp_safe_redirect(), which lands on wp-admin.
* chore: drop stale PHPStan baseline entry
wc_get_page_permalink() always returns a string, so the string|false
@param mismatch in EmailActionController no longer occurs.
diff --git a/plugins/woocommerce/changelog/fix-wooplug-7914-bis-invalid-link-notice b/plugins/woocommerce/changelog/fix-wooplug-7914-bis-invalid-link-notice
new file mode 100644
index 00000000000..57ff879df2b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-7914-bis-invalid-link-notice
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show a notice when a back-in-stock verification or unsubscribe link is expired, already used, or no longer valid.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index bbc1e3b7fc5..358d7c21fa2 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -65748,12 +65748,6 @@ parameters:
count: 1
path: src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifyEmail.php
- -
- message: '#^@param string \$url does not accept actual type of parameter\: string\|false\.$#'
- identifier: parameter.phpDocType
- count: 2
- path: src/Internal/StockNotifications/Emails/EmailActionController.php
-
-
message: '#^Cannot call method get_name\(\) on WC_Product\|false\|null\.$#'
identifier: method.nonObject
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php
index 551d5f5a4a3..e8c64f773db 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php
@@ -103,6 +103,11 @@ class EmailActionController {
$notification = $this->get_notification_to_be_processed( $notification_id );
if ( ! $notification ) {
+ // A deleted sign-up gets the same generic notice as a bad key, so the
+ // response does not reveal whether the notification ever existed.
+ if ( self::ACTION_VERIFY === $action || self::ACTION_UNSUBSCRIBE === $action ) {
+ $this->redirect_with_notice( $action, $this->get_invalid_link_notice_text( $action ), 'error' );
+ }
return;
}
@@ -132,94 +137,143 @@ class EmailActionController {
}
/**
- * If the verification key matches, it updates the notification status to active.
+ * Verifies a pending notification when the verification key matches.
+ *
+ * Expired, superseded or otherwise invalid keys, as well as notifications
+ * that are no longer pending, redirect with a notice instead of failing silently.
*
* @param Notification $notification The notification to process.
* @param string $action_key The action key to verify.
* @return void
*/
private function process_verification_action( Notification $notification, string $action_key ): void {
- if ( $notification->check_verification_key( $action_key ) ) {
- // Guard against re-hits of a still-valid verification URL (double-click, email prefetch,
- // link-scanner bots). Without this, each hit would re-dispatch the verified email.
- if ( NotificationStatus::ACTIVE === $notification->get_status() ) {
- return;
- }
+ // A mismatched key gets the generic notice regardless of status, so the
+ // response does not reveal the sign-up's state to anyone guessing keys.
+ if ( ! $notification->verification_key_matches( $action_key ) ) {
+ $this->redirect_with_notice( self::ACTION_VERIFY, $this->get_invalid_link_notice_text( self::ACTION_VERIFY ), 'error' );
+ }
- $notification->set_status( NotificationStatus::ACTIVE );
- $notification->set_date_confirmed( time() );
- $notification->save();
+ // Guard against re-hits of a verification URL (double-click, email prefetch,
+ // link-scanner bots). Without this, each hit would re-dispatch the verified email.
+ // Checked before expiry so a verified sign-up reports as verified even once its link is stale.
+ $status = $notification->get_status();
+ if ( NotificationStatus::PENDING !== $status ) {
+ $notice_text = NotificationStatus::CANCELLED === $status
+ ? esc_html__( 'This sign-up has been cancelled, so it can no longer be verified.', 'woocommerce' )
+ : esc_html__( 'This sign-up has already been verified.', 'woocommerce' );
+ $this->redirect_with_notice( self::ACTION_VERIFY, $notice_text, 'notice' );
+ }
- /**
- * Action: woocommerce_customer_stock_notifications_verified
- *
- * Fires after a stock-notification signup has been verified via the
- * double opt-in email link. Mirrors `woocommerce_customer_stock_notifications_signup`.
- *
- * @since 10.9.0
- *
- * @param Notification $notification The notification.
- */
- do_action( 'woocommerce_customer_stock_notifications_verified', $notification );
+ if ( $notification->is_verification_key_expired() ) {
+ $this->redirect_with_notice( self::ACTION_VERIFY, esc_html__( 'This verification link has expired. Sign up again to request a new one.', 'woocommerce' ), 'error' );
+ }
- $this->email_manager->send_verified_email( $notification );
+ $notification->set_status( NotificationStatus::ACTIVE );
+ $notification->set_date_confirmed( time() );
+ $notification->save();
- // We need a cookie-based session for notices to work on frontend pages.
- if ( WC()->session instanceof \WC_Session_Handler && ! WC()->session->has_session() ) {
- WC()->session->set_customer_session_cookie( true );
- }
+ /**
+ * Action: woocommerce_customer_stock_notifications_verified
+ *
+ * Fires after a stock-notification signup has been verified via the
+ * double opt-in email link. Mirrors `woocommerce_customer_stock_notifications_signup`.
+ *
+ * @since 10.9.0
+ *
+ * @param Notification $notification The notification.
+ */
+ do_action( 'woocommerce_customer_stock_notifications_verified', $notification );
- $product = wc_get_product( $notification->get_product_id() );
+ $this->email_manager->send_verified_email( $notification );
- /* translators: %s is product name */
- $notice_text = sprintf( esc_html__( 'Successfully verified stock notifications for "%s".', 'woocommerce' ), $product->get_name() );
- wc_add_notice( $notice_text );
- /**
- * `woocommerce_customer_stock_notification_verified_redirect_url` filter.
- *
- * @since 10.2.0
- *
- * @param string $url
- * @return string
- */
- $url = apply_filters( 'woocommerce_customer_stock_notification_verified_redirect_url', get_permalink( wc_get_page_id( 'shop' ) ) );
- wp_safe_redirect( $url );
- exit;
- }
+ $product = wc_get_product( $notification->get_product_id() );
+
+ /* translators: %s is product name */
+ $notice_text = sprintf( esc_html__( 'Successfully verified stock notifications for "%s".', 'woocommerce' ), $product->get_name() );
+ $this->redirect_with_notice( self::ACTION_VERIFY, $notice_text );
}
/**
* If the unsubscribe key matches, it cancels the notification, unless it was already sent or cancelled.
*
+ * An invalid key redirects with a notice instead of failing silently.
+ *
* @param Notification $notification The Notification to process.
* @param string $action_key The action key to verify.
* @return void
*/
private function process_unsubscribe_action( Notification $notification, string $action_key ): void {
- if ( $notification->check_unsubscribe_key( $action_key ) ) {
- // We need a cookie-based session for notices to work on frontend pages.
- if ( WC()->session instanceof \WC_Session_Handler && ! WC()->session->has_session() ) {
- WC()->session->set_customer_session_cookie( true );
- }
+ if ( ! $notification->check_unsubscribe_key( $action_key ) ) {
+ $this->redirect_with_notice( self::ACTION_UNSUBSCRIBE, $this->get_invalid_link_notice_text( self::ACTION_UNSUBSCRIBE ), 'error' );
+ }
- $status = $notification->get_status();
+ $status = $notification->get_status();
- if ( NotificationStatus::SENT === $status ) {
- wc_add_notice( esc_html__( 'This notification has already been sent, so there is nothing to unsubscribe from.', 'woocommerce' ), 'notice' );
- } elseif ( NotificationStatus::ACTIVE !== $status && NotificationStatus::PENDING !== $status ) {
- wc_add_notice( esc_html__( 'You are already unsubscribed from this notification.', 'woocommerce' ), 'notice' );
- } else {
- $notification->set_status( NotificationStatus::CANCELLED );
- $notification->set_cancellation_source( NotificationCancellationSource::USER );
- $notification->set_date_cancelled( time() );
- $notification->save();
+ if ( NotificationStatus::SENT === $status ) {
+ $this->redirect_with_notice( self::ACTION_UNSUBSCRIBE, esc_html__( 'This notification has already been sent, so there is nothing to unsubscribe from.', 'woocommerce' ), 'notice' );
+ }
- $product = wc_get_product( $notification->get_product_id() );
+ if ( NotificationStatus::ACTIVE !== $status && NotificationStatus::PENDING !== $status ) {
+ $this->redirect_with_notice( self::ACTION_UNSUBSCRIBE, esc_html__( 'You are already unsubscribed from this notification.', 'woocommerce' ), 'notice' );
+ }
- /* translators: %2$s product name, %1$s user email */
- $notice_text = sprintf( esc_html__( 'Successfully unsubscribed %1$s. You will not receive a notification when "%2$s" becomes available.', 'woocommerce' ), $notification->get_user_email(), $product->get_name() );
- wc_add_notice( $notice_text );
- }
+ $notification->set_status( NotificationStatus::CANCELLED );
+ $notification->set_cancellation_source( NotificationCancellationSource::USER );
+ $notification->set_date_cancelled( time() );
+ $notification->save();
+
+ $product = wc_get_product( $notification->get_product_id() );
+
+ /* translators: %2$s product name, %1$s user email */
+ $notice_text = sprintf( esc_html__( 'Successfully unsubscribed %1$s. You will not receive a notification when "%2$s" becomes available.', 'woocommerce' ), $notification->get_user_email(), $product->get_name() );
+ $this->redirect_with_notice( self::ACTION_UNSUBSCRIBE, $notice_text );
+ }
+
+ /**
+ * Returns the generic notice shown when an email link cannot be matched to a sign-up.
+ *
+ * @since 11.3.0
+ *
+ * @param string $action The email action: 'verify' or 'unsubscribe'.
+ * @return string
+ */
+ private function get_invalid_link_notice_text( string $action ): string {
+ return self::ACTION_VERIFY === $action
+ ? esc_html__( 'This verification link is no longer valid. Please use the link in the most recent verification email.', 'woocommerce' )
+ : esc_html__( 'This unsubscribe link is no longer valid.', 'woocommerce' );
+ }
+
+ /**
+ * Adds a frontend notice and redirects to the destination page of the given email action.
+ *
+ * @since 11.3.0
+ *
+ * @param string $action The email action: 'verify' or 'unsubscribe'.
+ * @param string $notice_text The notice text to show after the redirect.
+ * @param string $notice_type The notice type: 'success', 'notice' or 'error'.
+ * @return never
+ */
+ private function redirect_with_notice( string $action, string $notice_text, string $notice_type = 'success' ): void {
+ // We need a cookie-based session for notices to work on frontend pages.
+ if ( WC()->session instanceof \WC_Session_Handler && ! WC()->session->has_session() ) {
+ WC()->session->set_customer_session_cookie( true );
+ }
+
+ wc_add_notice( $notice_text, $notice_type );
+
+ $url = wc_get_page_permalink( 'shop' );
+
+ if ( self::ACTION_VERIFY === $action ) {
+ /**
+ * `woocommerce_customer_stock_notification_verified_redirect_url` filter.
+ *
+ * @since 10.2.0
+ *
+ * @param string $url
+ * @return string
+ */
+ $url = apply_filters( 'woocommerce_customer_stock_notification_verified_redirect_url', $url );
+ } else {
/**
* `woocommerce_customer_stock_notification_unsubscribe_redirect_url` filter.
*
@@ -228,10 +282,11 @@ class EmailActionController {
* @param string $url
* @return string
*/
- $url = apply_filters( 'woocommerce_customer_stock_notification_unsubscribe_redirect_url', get_permalink( wc_get_page_id( 'shop' ) ) );
- wp_safe_redirect( $url );
- exit;
+ $url = apply_filters( 'woocommerce_customer_stock_notification_unsubscribe_redirect_url', $url );
}
+
+ wp_safe_redirect( $url );
+ exit;
}
/**
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Notification.php b/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
index 6b93164eb8c..162d38cc492 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
@@ -483,20 +483,48 @@ class Notification extends \WC_Data {
* @return bool True if the key is valid, false otherwise.
*/
public function check_verification_key( string $key ): bool {
+ return $this->verification_key_matches( $key ) && ! $this->is_verification_key_expired();
+ }
+
+ /**
+ * Check whether the given key matches the stored verification key hash, ignoring expiration.
+ *
+ * @since 11.3.0
+ *
+ * @param string $key The key to check.
+ * @return bool True if a verification key is stored and the given key matches it, false otherwise.
+ */
+ public function verification_key_matches( string $key ): bool {
$action_key = (string) $this->get_meta( 'verification_action_key' );
if ( ! str_contains( $action_key, ':' ) ) {
return false;
}
- list( $timestamp, $hash ) = explode( ':', $action_key, 2 );
+ list( , $hash ) = explode( ':', $action_key, 2 );
+
+ return wp_verify_fast_hash( $key, $hash );
+ }
+
+ /**
+ * Check whether the stored verification key is past its expiration time.
+ *
+ * A missing or malformed key is not reported as expired.
+ *
+ * @since 11.3.0
+ *
+ * @return bool True if a verification key is stored and has expired, false otherwise.
+ */
+ public function is_verification_key_expired(): bool {
+ $action_key = (string) $this->get_meta( 'verification_action_key' );
- $threshold = Config::get_verification_expiration_time_threshold();
- if ( time() - (int) $timestamp > $threshold ) {
+ if ( ! str_contains( $action_key, ':' ) ) {
return false;
}
- return wp_verify_fast_hash( $key, $hash );
+ list( $timestamp ) = explode( ':', $action_key, 2 );
+
+ return time() - (int) $timestamp > Config::get_verification_expiration_time_threshold();
}
/**
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-confirmations.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-confirmations.spec.ts
index 18c538f317c..a75727dd2ac 100644
--- a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-confirmations.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-confirmations.spec.ts
@@ -261,8 +261,10 @@ test.describe(
await page.goto( corruptEmailLinkKey( verifyLink ) );
- // A rejected key is a no-op: no redirect to the shop, no notice.
- await expect( page ).toHaveURL( /notification_id=/ );
+ // A rejected key changes nothing, but the shopper is told why.
+ await expect(
+ page.getByText( bisNotice.verifyLinkInvalid, { exact: true } )
+ ).toBeVisible();
await expect(
page.getByText( bisNotice.verified( product.name ) )
).toHaveCount( 0 );
@@ -301,7 +303,9 @@ test.describe(
await expireVerificationLinks( page );
await page.goto( verifyLink );
- await expect( page ).toHaveURL( /notification_id=/ );
+ await expect(
+ page.getByText( bisNotice.verifyLinkExpired, { exact: true } )
+ ).toBeVisible();
await expect(
page.getByText( bisNotice.verified( product.name ) )
).toHaveCount( 0 );
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts
index 11a120071f2..6500a0d1c75 100644
--- a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts
@@ -207,8 +207,12 @@ test.describe(
await page.goto( corruptEmailLinkKey( unsubscribeLink ) );
- // A rejected key is a no-op: no redirect to the shop, no notice.
- await expect( page ).toHaveURL( /notification_id=/ );
+ // A rejected key changes nothing, but the shopper is told why.
+ await expect(
+ page.getByText( bisNotice.unsubscribeLinkInvalid, {
+ exact: true,
+ } )
+ ).toBeVisible();
await expect(
page.getByText( bisNotice.alreadySent, { exact: true } )
).toHaveCount( 0 );
@@ -221,7 +225,7 @@ test.describe(
await expect( row.getByText( /Cancelled/i ) ).toHaveCount( 0 );
// Positive control: the untouched link is accepted, so the page
- // above showed no notice because of the key and not because the
+ // above rejected the link because of the key and not because the
// link is broken.
await page.goto( unsubscribeLink );
await expect(
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 0f6baac3b38..4b076b6cbdf 100644
--- a/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
+++ b/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
@@ -888,6 +888,22 @@ export const bisNotice = {
*/
alreadySent:
'This notification has already been sent, so there is nothing to unsubscribe from.',
+ /**
+ * Printed on the shop page after a verify link whose key does not match
+ * the stored one (tampered, or superseded by a resend) is followed.
+ */
+ verifyLinkInvalid:
+ 'This verification link is no longer valid. Please use the link in the most recent verification email.',
+ /**
+ * Printed on the shop page after an expired verify link is followed.
+ */
+ verifyLinkExpired:
+ 'This verification link has expired. Sign up again to request a new one.',
+ /**
+ * Printed on the shop page after an unsubscribe link with a rejected key
+ * is followed.
+ */
+ unsubscribeLinkInvalid: 'This unsubscribe link is no longer valid.',
errors: {
invalidEmail: /Invalid email address\./,
invalidProduct: /Invalid product\./,
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php
index 15b7da12a8c..2e19e367bde 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php
@@ -3,6 +3,7 @@
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Emails;
+use Automattic\WooCommerce\Internal\StockNotifications\Config;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailActionController;
use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
@@ -88,6 +89,22 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
return $notification->save();
}
+ /**
+ * Run an email action request and assert it ends in a redirect.
+ *
+ * @param int $id Notification id.
+ * @param string $key Action key sent with the request.
+ * @param string $action Email action: 'verify' or 'unsubscribe'.
+ */
+ private function assert_request_redirects( int $id, string $key, string $action ): void {
+ try {
+ $this->sut->validate_and_maybe_process_request( $id, $key, $action );
+ $this->fail( 'Expected redirect to be intercepted via exception.' );
+ } catch ( \RuntimeException $e ) {
+ $this->assertStringContainsString( 'wp_redirect intercepted', $e->getMessage() );
+ }
+ }
+
/**
* @testdox Should set notification status to active when verification key matches.
*/
@@ -151,7 +168,7 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
->expects( $this->never() )
->method( 'send_verified_email' );
- $this->sut->validate_and_maybe_process_request( $id, 'wrong-key', 'verify' );
+ $this->assert_request_redirects( $id, 'wrong-key', 'verify' );
}
/**
@@ -181,7 +198,7 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
}
// Second hit (double-click, email prefetch, bot) must short-circuit without re-dispatch.
- $this->sut->validate_and_maybe_process_request( $id, 'test', 'verify' );
+ $this->assert_request_redirects( $id, 'test', 'verify' );
}
/**
@@ -271,8 +288,7 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
}
/**
- * A verification request with a key that doesn't match the stored one must
- * leave the notification untouched.
+ * @testdox Should show an error notice and leave the notification pending when the verification key is garbage.
*/
public function test_process_verification_action_with_invalid_key_leaves_status_pending() {
$id = $this->arrange_notification(
@@ -281,9 +297,137 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
time() . ':' . wp_fast_hash( 'real-key' )
);
- $this->sut->validate_and_maybe_process_request( $id, 'wrong-key', 'verify' );
+ $this->assert_request_redirects( $id, 'wrong-key', 'verify' );
+
+ $this->assertEquals( NotificationStatus::PENDING, Factory::get_notification( $id )->get_status() );
+ $this->assertSame( 1, wc_notice_count( 'error' ) );
+ $this->assertSame( 0, wc_notice_count( 'success' ) );
+ }
+
+ /**
+ * @testdox Should show an error notice and leave the notification pending when the link was superseded by a resend.
+ */
+ public function test_process_verification_action_with_superseded_key_leaves_status_pending() {
+ $id = $this->arrange_notification(
+ NotificationStatus::PENDING,
+ 'verification_action_key',
+ time() . ':' . wp_fast_hash( 'first-email-key' )
+ );
+ // Resending the verification email rotates the stored key.
+ Factory::get_notification( $id )->get_verification_key( true );
+
+ $this->assert_request_redirects( $id, 'first-email-key', 'verify' );
$this->assertEquals( NotificationStatus::PENDING, Factory::get_notification( $id )->get_status() );
+ $this->assertSame( 1, wc_notice_count( 'error' ) );
+ $this->assertSame( 0, wc_notice_count( 'success' ) );
+ }
+
+ /**
+ * @testdox Should show an error notice and leave the notification pending when the verification link has expired.
+ */
+ public function test_process_verification_action_with_expired_key_leaves_status_pending() {
+ $id = $this->arrange_notification(
+ NotificationStatus::PENDING,
+ 'verification_action_key',
+ ( time() - Config::get_verification_expiration_time_threshold() - MINUTE_IN_SECONDS ) . ':' . wp_fast_hash( 'test' )
+ );
+
+ $this->email_manager
+ ->expects( $this->never() )
+ ->method( 'send_verified_email' );
+
+ $this->assert_request_redirects( $id, 'test', 'verify' );
+
+ $this->assertEquals( NotificationStatus::PENDING, Factory::get_notification( $id )->get_status() );
+ $this->assertSame( 1, wc_notice_count( 'error' ) );
+ $this->assertSame( 0, wc_notice_count( 'success' ) );
+ }
+
+ /**
+ * @testdox Should show a neutral notice and leave the notification unchanged when it is already verified.
+ */
+ public function test_process_verification_action_on_already_verified_notification_leaves_it_unchanged() {
+ $id = $this->arrange_notification( NotificationStatus::ACTIVE, 'verification_action_key', time() . ':' . wp_fast_hash( 'test' ) );
+ $notification = Factory::get_notification( $id );
+ $notification->set_date_confirmed( time() - DAY_IN_SECONDS );
+ $notification->save();
+ $date_confirmed = Factory::get_notification( $id )->get_date_confirmed();
+
+ $this->email_manager
+ ->expects( $this->never() )
+ ->method( 'send_verified_email' );
+
+ $this->assert_request_redirects( $id, 'test', 'verify' );
+
+ $updated = Factory::get_notification( $id );
+ $this->assertEquals( NotificationStatus::ACTIVE, $updated->get_status() );
+ $this->assertNotNull( $date_confirmed );
+ $this->assertEquals( $date_confirmed->getTimestamp(), $updated->get_date_confirmed()->getTimestamp() );
+ $this->assertSame( 1, wc_notice_count( 'notice' ) );
+ $this->assertSame( 0, wc_notice_count( 'success' ) );
+ $this->assertSame( 0, wc_notice_count( 'error' ) );
+ }
+
+ /**
+ * @testdox Should report an already-verified sign-up as verified even after its verification link has expired.
+ */
+ public function test_process_verification_action_on_already_verified_notification_with_expired_key_reports_verified() {
+ $id = $this->arrange_notification(
+ NotificationStatus::ACTIVE,
+ 'verification_action_key',
+ ( time() - Config::get_verification_expiration_time_threshold() - MINUTE_IN_SECONDS ) . ':' . wp_fast_hash( 'test' )
+ );
+
+ $this->email_manager
+ ->expects( $this->never() )
+ ->method( 'send_verified_email' );
+
+ $this->assert_request_redirects( $id, 'test', 'verify' );
+
+ $this->assertEquals( NotificationStatus::ACTIVE, Factory::get_notification( $id )->get_status() );
+ $this->assertSame( 1, wc_notice_count( 'notice' ) );
+ $this->assertSame( 0, wc_notice_count( 'error' ) );
+ }
+
+ /**
+ * @testdox Should show a generic error notice when the $action link points at a notification that no longer exists.
+ * @dataProvider provider_email_actions
+ *
+ * @param string $action Email action: 'verify' or 'unsubscribe'.
+ */
+ public function test_process_action_with_missing_notification_shows_error_notice( string $action ) {
+ $this->assert_request_redirects( PHP_INT_MAX, 'any-key', $action );
+
+ $this->assertSame( 1, wc_notice_count( 'error' ) );
+ $this->assertSame( 0, wc_notice_count( 'success' ) );
+ }
+
+ /**
+ * Email actions that reach the shopper via a link.
+ *
+ * @return array[]
+ */
+ public function provider_email_actions(): array {
+ return array(
+ array( 'verify' ),
+ array( 'unsubscribe' ),
+ );
+ }
+
+ /**
+ * @testdox Should show an error notice and leave the notification unchanged when the unsubscribe key is garbage.
+ */
+ public function test_process_unsubscribe_action_with_invalid_key_leaves_notification_unchanged() {
+ $id = $this->arrange_notification( NotificationStatus::ACTIVE, 'unsubscribe_action_key', wp_fast_hash( 'real-key' ) );
+
+ $this->assert_request_redirects( $id, 'wrong-key', 'unsubscribe' );
+
+ $updated = Factory::get_notification( $id );
+ $this->assertEquals( NotificationStatus::ACTIVE, $updated->get_status() );
+ $this->assertNull( $updated->get_date_cancelled() );
+ $this->assertSame( 1, wc_notice_count( 'error' ) );
+ $this->assertSame( 0, wc_notice_count( 'success' ) );
}
/**
@@ -299,7 +443,7 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
time() . ':' . wp_fast_hash( 'test' )
);
- $this->sut->validate_and_maybe_process_request( $id, 'test', 'unsubscribe' );
+ $this->assert_request_redirects( $id, 'test', 'unsubscribe' );
$this->assertEquals( NotificationStatus::ACTIVE, Factory::get_notification( $id )->get_status() );
}