diff --git a/packages/123done/static/index.html b/packages/123done/static/index.html
index 2654702d2d5..d936bba1cce 100644
--- a/packages/123done/static/index.html
+++ b/packages/123done/static/index.html
@@ -86,6 +86,9 @@
SP3 - Sub to Pro 1m (EN GB)
+
+ SP3 - Sub to Pro Plus 1m
+
diff --git a/packages/123done/static/js/123done.js b/packages/123done/static/js/123done.js
index 72015c87992..0c8b44c91fe 100644
--- a/packages/123done/static/js/123done.js
+++ b/packages/123done/static/js/123done.js
@@ -41,6 +41,7 @@ $(document).ready(function () {
'sp3-6m': '123donepro/halfyearly/landing',
'sp3-12m': '123donepro/yearly/landing',
'sp3-1m-gb': 'en-GB/123donepro/monthly/landing',
+ 'sp3-plus-1m': '123doneproplus/monthly/landing',
},
};
diff --git a/packages/functional-tests/lib/fixtures/standard.ts b/packages/functional-tests/lib/fixtures/standard.ts
index f6fd3bd4544..e283184725c 100644
--- a/packages/functional-tests/lib/fixtures/standard.ts
+++ b/packages/functional-tests/lib/fixtures/standard.ts
@@ -35,6 +35,8 @@ export type TestOptions = {
};
export type WorkerOptions = { targetName: TargetName; target: ServerTarget };
+const CI_WAF_TOKEN = process.env.CI_WAF_TOKEN;
+
export const test = base.extend({
targetName: ['local', { scope: 'worker', option: true }],
@@ -47,6 +49,33 @@ export const test = base.extend({
{ scope: 'worker', auto: true },
],
+ page: async ({ page, target }, use) => {
+ // Add WAF bypass header only to FXA domains, not external
+ // services like Stripe/hCaptcha which reject unknown headers
+ if (CI_WAF_TOKEN) {
+ const fxaDomains = [
+ new URL(target.contentServerUrl).host,
+ new URL(target.authServerUrl).host,
+ new URL(target.paymentsNextUrl).host,
+ new URL(target.relierUrl).host,
+ ];
+ await page.route('**/*', async (route) => {
+ const url = new URL(route.request().url());
+ if (fxaDomains.some((domain) => url.host === domain)) {
+ await route.continue({
+ headers: {
+ ...route.request().headers(),
+ 'fxa-ci': CI_WAF_TOKEN,
+ },
+ });
+ } else {
+ await route.continue();
+ }
+ });
+ }
+ await use(page);
+ },
+
pages: async ({ target, page }, use) => {
const pages = createPages(page, target);
await use(pages);
diff --git a/packages/functional-tests/lib/stripe-test-cards.ts b/packages/functional-tests/lib/stripe-test-cards.ts
index 611c52e85b0..2424f31e7c4 100644
--- a/packages/functional-tests/lib/stripe-test-cards.ts
+++ b/packages/functional-tests/lib/stripe-test-cards.ts
@@ -9,6 +9,7 @@
export const StripeTestCards = {
SUCCESS: '4242424242424242',
+ SUCCESS_MASTERCARD: '5555555555554444',
THREE_DS_AUTHENTICATE: '4000000000003220',
DECLINED: '4000000000000002',
INSUFFICIENT_FUNDS: '4000000000009995',
diff --git a/packages/functional-tests/pages/payments/cancel.ts b/packages/functional-tests/pages/payments/cancel.ts
new file mode 100644
index 00000000000..ed1317122ed
--- /dev/null
+++ b/packages/functional-tests/pages/payments/cancel.ts
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Page, expect } from '@playwright/test';
+import { BaseTarget } from '../../lib/targets/base';
+
+export class CancelPage {
+ constructor(
+ public page: Page,
+ readonly target: BaseTarget
+ ) {}
+
+ // Cancel confirmation page
+
+ get cancelHeading() {
+ return this.page.locator('#cancel-subscription-heading');
+ }
+
+ get confirmCheckbox() {
+ return this.page.locator('input#cancelAccess[type="checkbox"]');
+ }
+
+ get submitCancelButton() {
+ return this.page.getByRole('button', {
+ name: /Cancel your subscription to/i,
+ });
+ }
+
+ get keepSubscriptionButton() {
+ return this.page.getByRole('link', { name: /Keep subscription/i });
+ }
+
+ // Already-cancelled state
+
+ get expirationDate() {
+ return this.page.getByText(/You will still have access .* until/);
+ }
+
+ get productName() {
+ return this.cancelHeading;
+ }
+
+ // Error state
+
+ get errorMessage() {
+ return this.page.locator('[role="alert"]');
+ }
+
+ // Actions
+
+ /**
+ * Check the acknowledge checkbox and click the cancel button.
+ */
+ async confirmAndCancel() {
+ await expect(this.confirmCheckbox).toBeVisible({ timeout: 10_000 });
+ await this.confirmCheckbox.click();
+ await expect(this.confirmCheckbox).toBeChecked();
+ await this.submitCancelButton.click();
+ }
+
+ /**
+ * Wait for the already-cancelled confirmation state.
+ */
+ async waitForCancelConfirmation(timeout = 30_000) {
+ await expect(this.page.getByText(/sorry to see you go/i)).toBeVisible({
+ timeout,
+ });
+ }
+}
diff --git a/packages/functional-tests/pages/payments/index.ts b/packages/functional-tests/pages/payments/index.ts
index 012f6a98318..a6c973c308a 100644
--- a/packages/functional-tests/pages/payments/index.ts
+++ b/packages/functional-tests/pages/payments/index.ts
@@ -4,10 +4,16 @@
import { Page } from '@playwright/test';
import { BaseTarget } from '../../lib/targets/base';
+import { CancelPage } from './cancel';
import { CheckoutPage } from './checkout';
+import { ManagePage } from './manage';
+import { StaySubscribedPage } from './stay-subscribed';
export function create(page: Page, target: BaseTarget) {
return {
+ cancel: new CancelPage(page, target),
checkout: new CheckoutPage(page, target),
+ manage: new ManagePage(page, target),
+ staySubscribed: new StaySubscribedPage(page, target),
};
}
diff --git a/packages/functional-tests/pages/payments/manage.ts b/packages/functional-tests/pages/payments/manage.ts
new file mode 100644
index 00000000000..f84218e483a
--- /dev/null
+++ b/packages/functional-tests/pages/payments/manage.ts
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Page, expect } from '@playwright/test';
+import { BaseTarget } from '../../lib/targets/base';
+
+export class ManagePage {
+ constructor(
+ public page: Page,
+ readonly target: BaseTarget
+ ) {}
+
+ // Page heading
+
+ get subscriptionHeading() {
+ return this.page.locator('#subscription-management');
+ }
+
+ // Active subscriptions section
+
+ get activeSubscriptionsList() {
+ return this.page.locator('ul[aria-label="Your active subscriptions"]');
+ }
+
+ getSubscriptionCard(productName: string) {
+ return this.page.locator(
+ `li[aria-labelledby="${productName}-information"]`
+ );
+ }
+
+ getProductName(productName: string) {
+ return this.page.locator(`#${productName}-information`);
+ }
+
+ getPlanInterval(productName: string) {
+ return this.getSubscriptionCard(productName).locator('.text-grey-500');
+ }
+
+ // Payment details section
+
+ get paymentDetailsSection() {
+ return this.page.locator('#payment-details');
+ }
+
+ get paymentMethodLastFour() {
+ return this.paymentDetailsSection.getByText(/Card ending in \d{4}/);
+ }
+
+ get paymentMethodIcon() {
+ return this.paymentDetailsSection.locator(
+ '.flex.items-center.gap-2 img'
+ );
+ }
+
+ get managePaymentButton() {
+ return this.page.locator(
+ 'a[aria-label="Manage payment method"]'
+ );
+ }
+
+ // Empty state
+
+ get emptyStateMessage() {
+ return this.page.getByText('You have no active subscriptions');
+ }
+
+ // Subscription actions
+
+ cancelButton() {
+ return this.page.getByRole('link', {
+ name: /Cancel your subscription to/i,
+ });
+ }
+
+ staySubscribedButton() {
+ return this.page.getByRole('link', {
+ name: /Stay subscribed to/i,
+ });
+ }
+
+ // Cancelled status
+
+ get cancelledStatus() {
+ return this.page.locator('.text-yellow-800').getByText(/Expires on/);
+ }
+
+ // Actions
+
+ /**
+ * Navigate to the subscriptions landing page, which triggers
+ * FXA auth and redirects to the manage page.
+ */
+ async goto(locale = 'en') {
+ await this.page.goto(
+ `${this.target.paymentsNextUrl}/${locale}/subscriptions/landing`
+ );
+ }
+
+ /**
+ * Wait for the manage page to fully load.
+ */
+ async waitForManagePage(timeout = 60_000) {
+ await expect(this.subscriptionHeading).toBeVisible({ timeout });
+ }
+}
diff --git a/packages/functional-tests/pages/payments/stay-subscribed.ts b/packages/functional-tests/pages/payments/stay-subscribed.ts
new file mode 100644
index 00000000000..af26f4a7eb2
--- /dev/null
+++ b/packages/functional-tests/pages/payments/stay-subscribed.ts
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Page, expect } from '@playwright/test';
+import { BaseTarget } from '../../lib/targets/base';
+
+export class StaySubscribedPage {
+ constructor(
+ public page: Page,
+ readonly target: BaseTarget
+ ) {}
+
+ // Resubscribe page
+
+ get staySubscribedHeading() {
+ return this.page.locator('#stay-subscribed-heading');
+ }
+
+ get productName() {
+ return this.staySubscribedHeading;
+ }
+
+ get nextChargeAmount() {
+ return this.page.getByText(/Your next charge will be/);
+ }
+
+ get resubscribeButton() {
+ return this.page.getByRole('button', { name: /Resubscribe/i });
+ }
+
+ // Success state
+
+ get successHeading() {
+ return this.page.getByText(/Thanks! You.re all set/i);
+ }
+
+ get backToSubscriptionsLink() {
+ return this.page.getByRole('link', { name: /Back to subscriptions/i });
+ }
+
+ // Error state
+
+ get errorMessage() {
+ return this.page.locator('[role="alert"]');
+ }
+
+ // Actions
+
+ /**
+ * Click the Resubscribe button and wait for success.
+ */
+ async resubscribe() {
+ await expect(this.resubscribeButton).toBeVisible({ timeout: 10_000 });
+ await this.resubscribeButton.click();
+ }
+
+ /**
+ * Wait for the resubscribe success confirmation.
+ */
+ async waitForSuccess(timeout = 30_000) {
+ await expect(this.successHeading).toBeVisible({ timeout });
+ }
+}
diff --git a/packages/functional-tests/pages/relier.ts b/packages/functional-tests/pages/relier.ts
index 5485b260721..85452ea9558 100644
--- a/packages/functional-tests/pages/relier.ts
+++ b/packages/functional-tests/pages/relier.ts
@@ -111,6 +111,17 @@ export class RelierPage extends BaseLayout {
);
}
+ // Uses 123doneproplus offering (no free trial) to test that subscription
+ // appears under "Active subscriptions" on the manage page
+ async clickSubscribePlusMonthly() {
+ await this.page
+ .getByRole('link', { name: 'SP3 - Sub to Pro Plus 1m', exact: true })
+ .click();
+ await this.page.waitForURL(
+ (url) => !url.href.includes(this.target.relierUrl)
+ );
+ }
+
async clickRequire2FA() {
await this.page.getByText('Sign In (Require 2FA)').click();
return this.page.waitForURL(`${this.target.contentServerUrl}/**`);
diff --git a/packages/functional-tests/playwright.config.ts b/packages/functional-tests/playwright.config.ts
index 1e9888df106..4a849502458 100644
--- a/packages/functional-tests/playwright.config.ts
+++ b/packages/functional-tests/playwright.config.ts
@@ -10,16 +10,6 @@ import { TargetNames } from './lib/targets';
import { getFirefoxUserPrefs } from './lib/targets/firefoxUserPrefs';
const CI = !!process.env.CI;
-const CI_WAF_TOKEN = process.env.CI_WAF_TOKEN;
-
-/**
- * Returns a header used for WAF bypass.
- *
- * Requires `CI_WAF_TOKEN` set in CircleCI, or local environment, and corresponding WAF condition set for target rule
- */
-function getCIHeader(): Record {
- return CI_WAF_TOKEN ? { 'fxa-ci': CI_WAF_TOKEN } : {};
-}
// If using the CircleCI parallelism feature, assure that the JUNIT XML report
// has a unique name
@@ -74,7 +64,6 @@ export default defineConfig>({
use: {
viewport: { width: 1280, height: 720 },
- extraHTTPHeaders: getCIHeader(),
},
projects: [
...TargetNames.map(
diff --git a/packages/functional-tests/tests-payments-next/subscription.spec.ts b/packages/functional-tests/tests-payments-next/subscription.spec.ts
new file mode 100644
index 00000000000..49529ac6513
--- /dev/null
+++ b/packages/functional-tests/tests-payments-next/subscription.spec.ts
@@ -0,0 +1,306 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { expect, test } from '../lib/fixtures/payments';
+import { StripeTestCards, TestCardDefaults } from '../lib/stripe-test-cards';
+
+// Subscription management tests require an existing subscription (created
+// via UI checkout). Run serially to avoid Stripe rate limits.
+test.describe.configure({ mode: 'serial' });
+
+test.describe('severity-1 #smoke', () => {
+ test.setTimeout(240_000);
+ test.use({ viewport: { width: 1280, height: 1080 } });
+
+ test.beforeEach(async ({}, { project }) => {
+ test.skip(
+ project.name.includes('production'),
+ 'Subscription management smoke tests are not run in production'
+ );
+ });
+
+ test('Manage page loads with active subscription', async ({
+ target,
+ page,
+ pages: { relier, signin },
+ paymentPages: { checkout, manage },
+ testAccountTracker,
+ }) => {
+ const credentials = await testAccountTracker.signUp();
+
+ // Create subscription via UI checkout
+ await relier.goto();
+ await relier.clickSubscribePlusMonthly();
+
+ await checkout.handleLocationIfNeeded();
+
+ await checkout.emailInput.fill(credentials.email);
+ await checkout.signInContinueButton.click();
+
+ await expect(page).toHaveURL(new RegExp(target.contentServerUrl), {
+ timeout: 30_000,
+ });
+ await signin.fillOutPasswordForm(credentials.password);
+
+ await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 });
+
+ await checkout.waitForStripeReady();
+ await checkout.checkConsent();
+ await checkout.fillCard(StripeTestCards.SUCCESS);
+ await checkout.submit();
+
+ await checkout.waitForSuccess();
+
+ // Navigate to manage page
+ await manage.goto();
+ await manage.waitForManagePage();
+
+ // Verify subscription card details
+ await expect(manage.activeSubscriptionsList).toBeVisible();
+ await expect(manage.paymentMethodLastFour).toBeVisible();
+ await expect(manage.paymentMethodIcon).toBeVisible();
+ });
+
+ test('Change payment method (Stripe)', async ({
+ target,
+ page,
+ pages: { relier, signin },
+ paymentPages: { checkout, manage },
+ testAccountTracker,
+ }) => {
+ const credentials = await testAccountTracker.signUp();
+
+ // Create subscription via UI checkout
+ await relier.goto();
+ await relier.clickSubscribePlusMonthly();
+
+ await checkout.handleLocationIfNeeded();
+
+ await checkout.emailInput.fill(credentials.email);
+ await checkout.signInContinueButton.click();
+
+ await expect(page).toHaveURL(new RegExp(target.contentServerUrl), {
+ timeout: 30_000,
+ });
+ await signin.fillOutPasswordForm(credentials.password);
+
+ await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 });
+
+ await checkout.waitForStripeReady();
+ await checkout.checkConsent();
+ await checkout.fillCard(StripeTestCards.SUCCESS);
+ await checkout.submit();
+
+ await checkout.waitForSuccess();
+
+ // Navigate to Stripe payment management page
+ await page.goto(
+ `${target.paymentsNextUrl}/en/subscriptions/payments/stripe`
+ );
+ await expect(page).toHaveURL(/payments\/stripe/, { timeout: 30_000 });
+
+ // Wait for the Stripe PaymentElement accordion to load
+ const stripeIframe = page.locator(
+ 'iframe[title*="Secure payment input frame"]'
+ );
+ await expect(stripeIframe).toBeVisible({ timeout: 30_000 });
+
+ // Click "Card" button in the accordion to expand the new card form
+ const stripeFrame = page.frameLocator(
+ 'iframe[title*="Secure payment input frame"]'
+ );
+ await stripeFrame.getByRole('button', { name: 'Card' }).click();
+
+ // Fill card fields directly — checkout.fillCard() unchecks the
+ // Stripe Link checkbox which breaks the payment management form
+ const cardNumber = stripeFrame.locator('[autocomplete="cc-number"]');
+ await expect(cardNumber).toBeAttached({ timeout: 10_000 });
+ await cardNumber.click();
+ await cardNumber.pressSequentially(StripeTestCards.SUCCESS_MASTERCARD, {
+ delay: 50,
+ });
+ const cardExpiry = stripeFrame.locator('[autocomplete="cc-exp"]');
+ await cardExpiry.click();
+ await cardExpiry.pressSequentially(
+ `${TestCardDefaults.EXP_MONTH}/${String(TestCardDefaults.EXP_YEAR).slice(-2)}`,
+ { delay: 50 }
+ );
+ const cardCvc = stripeFrame.locator('[autocomplete="cc-csc"]');
+ await cardCvc.click();
+ await cardCvc.pressSequentially('123', { delay: 50 });
+ const postalCode = stripeFrame.locator(
+ '[autocomplete="postal-code"], [name="postalCode"], [name="zip"]'
+ );
+ await postalCode.click();
+ await postalCode.pressSequentially('10001', { delay: 50 });
+
+ // Wait for the save button to become enabled — Stripe's onChange
+ // event needs to fire with complete:true after the last field is filled
+ const saveButton = page.getByRole('button', {
+ name: /Save payment method/i,
+ });
+ await expect(saveButton).toBeVisible({ timeout: 10_000 });
+ await expect(saveButton).not.toHaveAttribute('disabled', '', {
+ timeout: 10_000,
+ });
+ await saveButton.click();
+
+ // Wait for the save to process — the button shows a spinner,
+ // then the page redirects to manage via router.push
+ await expect(page).toHaveURL(/subscriptions\/manage/, { timeout: 90_000 });
+ await manage.waitForManagePage();
+ await expect(manage.paymentMethodLastFour).toContainText('4444');
+ });
+
+ test('Cancel subscription', async ({
+ target,
+ page,
+ pages: { relier, signin },
+ paymentPages: { checkout, manage, cancel },
+ testAccountTracker,
+ }) => {
+ const credentials = await testAccountTracker.signUp();
+
+ // Create subscription via UI checkout
+ await relier.goto();
+ await relier.clickSubscribePlusMonthly();
+
+ await checkout.handleLocationIfNeeded();
+
+ await checkout.emailInput.fill(credentials.email);
+ await checkout.signInContinueButton.click();
+
+ await expect(page).toHaveURL(new RegExp(target.contentServerUrl), {
+ timeout: 30_000,
+ });
+ await signin.fillOutPasswordForm(credentials.password);
+
+ await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 });
+
+ await checkout.waitForStripeReady();
+ await checkout.checkConsent();
+ await checkout.fillCard(StripeTestCards.SUCCESS);
+ await checkout.submit();
+
+ await checkout.waitForSuccess();
+
+ // Navigate to manage page and cancel
+ await manage.goto();
+ await manage.waitForManagePage();
+
+ await manage.cancelButton().click();
+
+ // Confirm cancellation
+ await expect(cancel.cancelHeading).toBeVisible({ timeout: 30_000 });
+ await cancel.confirmAndCancel();
+ await cancel.waitForCancelConfirmation();
+
+ // Navigate back to manage and verify cancelled status
+ await manage.goto();
+ await manage.waitForManagePage();
+ await expect(manage.cancelledStatus).toBeVisible();
+ });
+
+ test('Resubscribe cancelled subscription', async ({
+ target,
+ page,
+ pages: { relier, signin },
+ paymentPages: { checkout, manage, cancel, staySubscribed },
+ testAccountTracker,
+ }) => {
+ const credentials = await testAccountTracker.signUp();
+
+ // Create subscription via UI checkout
+ await relier.goto();
+ await relier.clickSubscribePlusMonthly();
+
+ await checkout.handleLocationIfNeeded();
+
+ await checkout.emailInput.fill(credentials.email);
+ await checkout.signInContinueButton.click();
+
+ await expect(page).toHaveURL(new RegExp(target.contentServerUrl), {
+ timeout: 30_000,
+ });
+ await signin.fillOutPasswordForm(credentials.password);
+
+ await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 });
+
+ await checkout.waitForStripeReady();
+ await checkout.checkConsent();
+ await checkout.fillCard(StripeTestCards.SUCCESS);
+ await checkout.submit();
+
+ await checkout.waitForSuccess();
+
+ // Cancel the subscription first
+ await manage.goto();
+ await manage.waitForManagePage();
+
+ await manage.cancelButton().click();
+
+ await expect(cancel.cancelHeading).toBeVisible({ timeout: 30_000 });
+ await cancel.confirmAndCancel();
+ await cancel.waitForCancelConfirmation();
+
+ // Navigate back to manage and resubscribe
+ await manage.goto();
+ await manage.waitForManagePage();
+
+ await manage.staySubscribedButton().click();
+
+ // Confirm resubscribe
+ await expect(staySubscribed.staySubscribedHeading).toBeVisible({
+ timeout: 30_000,
+ });
+ await staySubscribed.resubscribe();
+ await staySubscribed.waitForSuccess();
+
+ // Navigate back to manage and verify active again
+ await staySubscribed.backToSubscriptionsLink.click();
+ await manage.waitForManagePage();
+
+ // Cancelled status should no longer be visible — subscription is active
+ await expect(manage.cancelledStatus).toBeHidden();
+ await expect(manage.activeSubscriptionsList).toBeVisible();
+ });
+});
+
+test.describe('severity-2', () => {
+ test.setTimeout(180_000);
+ test.use({ viewport: { width: 1280, height: 1080 } });
+
+ test.beforeEach(async ({}, { project }) => {
+ test.skip(
+ project.name.includes('production'),
+ 'Subscription management tests are not run in production'
+ );
+ });
+
+ test('Manage page empty state', async ({
+ target,
+ page,
+ pages: { signin },
+ paymentPages: { manage },
+ testAccountTracker,
+ }) => {
+ const credentials = await testAccountTracker.signUp();
+
+ // Navigate to manage page (triggers FXA auth)
+ await manage.goto();
+
+ // Sign in when redirected to FXA
+ await expect(page).toHaveURL(new RegExp(target.contentServerUrl), {
+ timeout: 30_000,
+ });
+ await signin.fillOutEmailFirstForm(credentials.email);
+ await signin.fillOutPasswordForm(credentials.password);
+
+ // Wait for redirect back to manage page
+ await manage.waitForManagePage();
+
+ // Verify empty state
+ await expect(manage.emptyStateMessage).toBeVisible();
+ });
+});