diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index d9e623547..4c5693240 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -110,7 +110,7 @@ agreement, and cloud-specific enforcement rules. 14. Add or change shared commercial plan/usage presentation through `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` and `frontend-modern/src/utils/commercialBillingModel.ts` 15. Add or change organization billing and usage presentation through `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx`, and `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts` 16. Add or change self-hosted Pro plan, trial, recovery, and entitlement actions through `frontend-modern/src/components/Settings/ProLicensePanel.tsx`, `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx`, `frontend-modern/src/components/Settings/SelfHostedCommercialRecoverySection.tsx`, and `frontend-modern/src/components/Settings/useProLicensePanelState.ts` -17. Add or change monitored-system ledger presentation through `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx`, `frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx`, and `frontend-modern/src/utils/monitoredSystemPresentation.ts` +17. Add or change monitored-system ledger, disclosure, or admission-preview presentation through `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx`, and `frontend-modern/src/utils/monitoredSystemPresentation.ts` 18. Add or change paid relay settings and onboarding presentation through `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`, `frontend-modern/src/components/Settings/RelayPairingSection.tsx`, `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts`, `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx`, and `frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts` 19. Add or change cloud plan presentation through `frontend-modern/src/pages/CloudPricing.tsx` 20. Add contract tests where runtime and pricing need to stay aligned @@ -628,6 +628,10 @@ limit-warning banner, so the settings panel, Pro usage section, counting-rules disclosure, and shared warning-banner model must consume that helper instead of redefining customer-facing monitored-system copy inline or keeping a parallel copy in generic self-hosted plan utilities. +That same helper also owns preview-impact copy and current/projected +source-label wording for pre-save monitored-system admission UI, so the +TrueNAS and VMware settings panels do not drift into provider-local billing +phrasing when they surface canonical preview results before save. That same disclosure surface must not accept arbitrary caller-supplied monitored-system summary strings as its primary API. When the disclosure needs to show brief summary copy, it should render the canonical helper-owned brief @@ -699,6 +703,15 @@ for self-hosted plan comparison and checkout before returning through the runtime-owned activation callback. Pulse product routes keep ownership of license status, usage, and activation state; `Pulse Account` owns the commerce flow itself. +That same router-owned billing contract now also includes recovery as a plan +detail state instead of a fragment alias. The canonical recovery arrival is +`/settings/system/billing/plan?details=recovery`, while +`#pulse-pro-recovery` remains a compatibility deep link that must normalize +into that owned query state. `purchase=failed` arrival handling on the +self-hosted billing surface must consume the one-shot purchase result, preserve +the owned plan intent when present, and replace the URL with the canonical +recovery detail route so `Open recovery` lands on a first-class billing state +instead of a hash that settings-shell normalization strips away. That same ownership split is explicit in the governed registry as well: `CommercialBillingSections.tsx` is part of the shared commercial shell/model surface, while `SelfHostedCommercialRecoverySection.tsx` stays on the diff --git a/frontend-modern/src/components/Settings/ProLicensePanel.tsx b/frontend-modern/src/components/Settings/ProLicensePanel.tsx index ad571a10a..a5711016c 100644 --- a/frontend-modern/src/components/Settings/ProLicensePanel.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePanel.tsx @@ -59,27 +59,43 @@ export const ProLicensePanel: Component = () => { title={SELF_HOSTED_PRO_BILLING_PRESENTATION.planSectionTitle} description={SELF_HOSTED_PRO_BILLING_PRESENTATION.planSectionDescription} > - void state.loadPanelData()} - purchaseActivationAction={state.purchaseActivationAction()} - purchaseActivationNotice={state.purchaseActivationNotice()} - showMonitoredSystemUpgradeArrival={state.showMonitoredSystemUpgradeArrival()} - showTrialStart={state.showTrialStart()} - startingTrial={state.startingTrial()} - statusPresentation={state.statusPresentation()} - onStartTrial={() => void state.handleStartTrial()} - trialActivationNotice={state.trialActivationNotice()} - trialEnded={state.trialEnded()} - /> +
+ void state.loadPanelData()} + purchaseActivationAction={state.purchaseActivationAction()} + purchaseActivationNotice={state.purchaseActivationNotice()} + showMonitoredSystemUpgradeArrival={state.showMonitoredSystemUpgradeArrival()} + showTrialStart={state.showTrialStart()} + startingTrial={state.startingTrial()} + statusPresentation={state.statusPresentation()} + onStartTrial={() => void state.handleStartTrial()} + trialActivationNotice={state.trialActivationNotice()} + trialEnded={state.trialEnded()} + /> + + +
@@ -95,19 +111,6 @@ export const ProLicensePanel: Component = () => { /> - - diff --git a/frontend-modern/src/components/Settings/SelfHostedCommercialRecoverySection.tsx b/frontend-modern/src/components/Settings/SelfHostedCommercialRecoverySection.tsx index b5ea7edf1..4cfc99fb6 100644 --- a/frontend-modern/src/components/Settings/SelfHostedCommercialRecoverySection.tsx +++ b/frontend-modern/src/components/Settings/SelfHostedCommercialRecoverySection.tsx @@ -7,6 +7,7 @@ import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresent interface SelfHostedCommercialRecoverySectionProps { sectionId?: string; + open?: boolean; licenseKey: string; activating: boolean; clearing: boolean; @@ -26,7 +27,7 @@ export const SelfHostedCommercialRecoverySection: Component< title={SELF_HOSTED_PRO_BILLING_PRESENTATION.recoverySectionTitle} description={SELF_HOSTED_PRO_BILLING_PRESENTATION.recoverySectionDescription} > -
+
diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 89b82c727..09cf3ee84 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -9,6 +9,7 @@ import proLicensePanelStateSource from '../useProLicensePanelState.ts?raw'; import proLicensePlanSectionSource from '../ProLicensePlanSection.tsx?raw'; import selfHostedCommercialRecoverySectionSource from '../SelfHostedCommercialRecoverySection.tsx?raw'; import { + getSelfHostedBillingHref, getPublicPricingUrl, getSelfHostedPurchaseStartUrl, SELF_HOSTED_PRO_BILLING_PLAN_HREF, @@ -18,6 +19,7 @@ import { SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED, SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED, SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED, + SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID, SELF_HOSTED_PRO_BILLING_USAGE_HREF, SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID, @@ -482,9 +484,14 @@ describe('ProLicensePanel', () => { purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED, title: 'Activation needs attention', actionLabel: 'Open recovery', - actionHref: SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF, + actionHref: getSelfHostedBillingHref('plan', { + detail: SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, + }), + redirectedHref: getSelfHostedBillingHref('plan', { + detail: SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, + }), }, - ])('shows the purchase arrival notice for $purchase', async ({ purchase, title, actionLabel, actionHref }) => { + ])('shows the purchase arrival notice for $purchase', async ({ purchase, title, actionLabel, actionHref, redirectedHref = SELF_HOSTED_PRO_BILLING_PLAN_HREF }) => { useLocationMock.mockReturnValue({ search: `?purchase=${purchase}`, pathname: '/settings/system/billing/plan', @@ -499,7 +506,7 @@ describe('ProLicensePanel', () => { } else { expect(screen.queryByRole('link', { name: 'Review usage' })).not.toBeInTheDocument(); } - expect(navigateMock).toHaveBeenCalledWith(SELF_HOSTED_PRO_BILLING_PLAN_HREF, { + expect(navigateMock).toHaveBeenCalledWith(redirectedHref, { replace: true, scroll: false, }); @@ -522,6 +529,21 @@ describe('ProLicensePanel', () => { expect(screen.queryByText('Need a higher monitored-system cap?')).not.toBeInTheDocument(); }); + it('opens recovery by default when the billing route requests the recovery detail', async () => { + useLocationMock.mockReturnValue({ + search: '?details=recovery', + pathname: '/settings/system/billing/plan', + hash: '', + }); + + renderPanel(); + + const recoveryDisclosure = screen + .getByText('Redeem existing key') + .closest('details'); + expect(recoveryDisclosure).toHaveAttribute('open'); + }); + it('focuses the usage billing section when the usage route requests counting rules', async () => { useLocationMock.mockReturnValue({ search: '?details=counting-rules', diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index 86727c2ef..4edb1ac61 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -23,6 +23,8 @@ import { isDisplayableLicenseFeature, } from '@/utils/licensePresentation'; import { + getSelfHostedBillingHref, + getSelfHostedBillingPlanDetail, getSelfHostedBillingPlanIntent, getSelfHostedBillingPurchaseArrival, getSelfHostedBillingUsageDetail, @@ -30,11 +32,12 @@ import { resolveSelfHostedPurchaseStartDestination, SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL, SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT, - SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF, + SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED, SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED, SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED, SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED, + SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM, SELF_HOSTED_PRO_BILLING_PLAN_ROUTE, SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM, SELF_HOSTED_PRO_BILLING_USAGE_HREF, @@ -93,6 +96,15 @@ export function useProLicensePanelState() { if (purchaseResult) { setPurchaseActivationResult(purchaseResult); params.delete(SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM); + if ( + purchaseResult === SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED && + getSelfHostedBillingPlanDetail(location.search) !== SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL + ) { + params.set( + SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM, + SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, + ); + } } if (trialResult || purchaseResult) { const nextSearch = params.toString(); @@ -130,6 +142,11 @@ export function useProLicensePanelState() { getSelfHostedBillingUsageDetail(location.search) === SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL, ); + const showRecoveryByDefault = createMemo( + () => + activeSection() === 'plan' && + getSelfHostedBillingPlanDetail(location.search) === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, + ); const showTrialStart = createMemo(() => { const current = entitlements(); @@ -295,7 +312,12 @@ export function useProLicensePanelState() { case SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED: return { label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseFailedActionLabel, - destination: resolveUpgradeDestination(SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF), + destination: resolveUpgradeDestination( + getSelfHostedBillingHref('plan', { + intent, + detail: SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, + }), + ), }; default: return null; @@ -416,6 +438,7 @@ export function useProLicensePanelState() { setLicenseKey, showCountingRulesByDefault, showMonitoredSystemUpgradeArrival, + showRecoveryByDefault, showTrialStart, startingTrial, statusPresentation, diff --git a/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts b/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts index 632333409..6a9a45bbc 100644 --- a/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts +++ b/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { getSelfHostedPurchaseStartUrl, getSelfHostedBillingHref, + getSelfHostedBillingPlanDetail, getSelfHostedBillingPlanIntent, getSelfHostedBillingPurchaseArrival, getSelfHostedBillingUsageDetail, @@ -17,7 +18,9 @@ import { SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL, SELF_HOSTED_PRO_BILLING_PLAN_HREF, SELF_HOSTED_PRO_BILLING_PLAN_MONITORED_SYSTEM_UPGRADE_HREF, + SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF, SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED, + SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, SELF_HOSTED_PRO_BILLING_ROUTE, SELF_HOSTED_PRO_BILLING_USAGE_COUNTING_RULES_HREF, SELF_HOSTED_PRO_BILLING_USAGE_HREF, @@ -75,12 +78,21 @@ describe('pricingHandoff', () => { ).toBe( `${SELF_HOSTED_PRO_BILLING_PLAN_MONITORED_SYSTEM_UPGRADE_HREF}&purchase=${SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED}`, ); + expect( + getSelfHostedBillingHref('plan', { + intent: SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT, + detail: SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, + }), + ).toBe(`${SELF_HOSTED_PRO_BILLING_PLAN_MONITORED_SYSTEM_UPGRADE_HREF}&details=recovery`); }); it('canonicalizes legacy self-hosted billing aliases to route-owned states', () => { expect( resolveCanonicalSelfHostedBillingHref(SELF_HOSTED_PRO_BILLING_ROUTE, '', '#pulse-pro-usage'), ).toBe(SELF_HOSTED_PRO_BILLING_USAGE_HREF); + expect( + resolveCanonicalSelfHostedBillingHref(SELF_HOSTED_PRO_BILLING_ROUTE, '', '#pulse-pro-recovery'), + ).toBe(SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF); expect(resolveCanonicalSelfHostedBillingHref(SELF_HOSTED_PRO_BILLING_ROUTE)).toBe( SELF_HOSTED_PRO_BILLING_PLAN_HREF, ); @@ -95,6 +107,9 @@ describe('pricingHandoff', () => { expect( getSelfHostedBillingPlanIntent('?intent=' + SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT), ).toBe(SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT); + expect(getSelfHostedBillingPlanDetail('?details=' + SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL)).toBe( + SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, + ); expect( getSelfHostedBillingPurchaseArrival( '?purchase=' + SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED, diff --git a/frontend-modern/src/utils/pricingHandoff.ts b/frontend-modern/src/utils/pricingHandoff.ts index b1484a8b9..d5cc3dee5 100644 --- a/frontend-modern/src/utils/pricingHandoff.ts +++ b/frontend-modern/src/utils/pricingHandoff.ts @@ -14,10 +14,15 @@ export const SELF_HOSTED_PRO_BILLING_USAGE_ROUTE = `${SELF_HOSTED_PRO_BILLING_RO export const SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID = 'pulse-pro-plan'; export const SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID = 'pulse-pro-usage'; export const SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID = 'pulse-pro-recovery'; -export const SELF_HOSTED_PRO_BILLING_USAGE_DETAILS_QUERY_PARAM = 'details'; +export const SELF_HOSTED_PRO_BILLING_DETAILS_QUERY_PARAM = 'details'; +export const SELF_HOSTED_PRO_BILLING_USAGE_DETAILS_QUERY_PARAM = + SELF_HOSTED_PRO_BILLING_DETAILS_QUERY_PARAM; +export const SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM = + SELF_HOSTED_PRO_BILLING_DETAILS_QUERY_PARAM; export const SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM = 'intent'; export const SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM = 'purchase'; export const SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL = 'counting-rules'; +export const SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL = 'recovery'; export const SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT = 'max_monitored_systems'; export const SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED = 'activated'; export const SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED = 'cancelled'; @@ -26,6 +31,8 @@ export const SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED = 'failed'; export type SelfHostedBillingSection = 'plan' | 'usage'; export type SelfHostedBillingUsageDetail = typeof SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL; +export type SelfHostedBillingPlanDetail = typeof SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL; +export type SelfHostedBillingDetail = SelfHostedBillingUsageDetail | SelfHostedBillingPlanDetail; export type SelfHostedBillingPlanIntent = typeof SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT; export type SelfHostedBillingPurchaseArrival = | typeof SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED @@ -35,7 +42,7 @@ export type SelfHostedBillingPurchaseArrival = export const SELF_HOSTED_PRO_BILLING_PLAN_HREF = SELF_HOSTED_PRO_BILLING_PLAN_ROUTE; export const SELF_HOSTED_PRO_BILLING_USAGE_HREF = SELF_HOSTED_PRO_BILLING_USAGE_ROUTE; -export const SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF = `${SELF_HOSTED_PRO_BILLING_PLAN_ROUTE}#${SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID}`; +export const SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF = `${SELF_HOSTED_PRO_BILLING_PLAN_ROUTE}?${SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM}=${SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL}`; export const SELF_HOSTED_PRO_BILLING_USAGE_COUNTING_RULES_HREF = `${SELF_HOSTED_PRO_BILLING_USAGE_ROUTE}?${SELF_HOSTED_PRO_BILLING_USAGE_DETAILS_QUERY_PARAM}=${SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL}`; export const SELF_HOSTED_PRO_BILLING_PLAN_MONITORED_SYSTEM_UPGRADE_HREF = `${SELF_HOSTED_PRO_BILLING_PLAN_ROUTE}?${SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM}=${SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT}`; @@ -144,6 +151,13 @@ export function getSelfHostedBillingUsageDetail( return detail === SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL ? detail : null; } +export function getSelfHostedBillingPlanDetail(search: string): SelfHostedBillingPlanDetail | null { + const detail = billingSearch(search) + .get(SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM) + ?.trim(); + return detail === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL ? detail : null; +} + export function getSelfHostedBillingPlanIntent(search: string): SelfHostedBillingPlanIntent | null { const intent = billingSearch(search).get(SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM)?.trim(); return intent === SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT ? intent : null; @@ -167,7 +181,7 @@ export function getSelfHostedBillingPurchaseArrival( export function getSelfHostedBillingHref( section: SelfHostedBillingSection, options: { - detail?: SelfHostedBillingUsageDetail | null; + detail?: SelfHostedBillingDetail | null; intent?: SelfHostedBillingPlanIntent | null; purchase?: SelfHostedBillingPurchaseArrival | null; } = {}, @@ -190,6 +204,13 @@ export function getSelfHostedBillingHref( ); } + if (section === 'plan' && options.detail === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL) { + params.set( + SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM, + SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, + ); + } + if (options.purchase) { params.set(SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM, options.purchase); } @@ -222,9 +243,15 @@ export function resolveSelfHostedBillingSection( if (normalizedHash === SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID) { return 'plan'; } + if (normalizedHash === SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID) { + return 'plan'; + } if (getSelfHostedBillingUsageDetail(search) === SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL) { return 'usage'; } + if (getSelfHostedBillingPlanDetail(search) === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL) { + return 'plan'; + } return 'plan'; } @@ -239,18 +266,33 @@ export function resolveCanonicalSelfHostedBillingHref( } const section = resolveSelfHostedBillingSection(normalizedPath, search, hash); - const canonicalPath = - section === 'usage' ? SELF_HOSTED_PRO_BILLING_USAGE_ROUTE : SELF_HOSTED_PRO_BILLING_PLAN_ROUTE; - const normalizedSearch = normalizeSearch(search); + const normalizedHash = normalizeHash(hash); + const usageDetail = + section === 'usage' && + getSelfHostedBillingUsageDetail(search) === SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL + ? SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL + : null; + const planIntent = section === 'plan' ? getSelfHostedBillingPlanIntent(search) : null; + const planDetail = + section === 'plan' && + (getSelfHostedBillingPlanDetail(search) === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL || + normalizedHash === SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID) + ? SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL + : null; + const purchase = section === 'plan' ? getSelfHostedBillingPurchaseArrival(search) : null; - return `${canonicalPath}${normalizedSearch ? `?${normalizedSearch}` : ''}`; + return getSelfHostedBillingHref(section, { + detail: section === 'usage' ? usageDetail : planDetail, + intent: planIntent, + purchase, + }); } export function scopeSelfHostedBillingDestination( destination: UpgradeDestination, section: SelfHostedBillingSection, options: { - detail?: SelfHostedBillingUsageDetail | null; + detail?: SelfHostedBillingDetail | null; intent?: SelfHostedBillingPlanIntent | null; purchase?: SelfHostedBillingPurchaseArrival | null; } = {}, diff --git a/pkg/licensing/license_server_client.go b/pkg/licensing/license_server_client.go index 581b3391a..934fc9dde 100644 --- a/pkg/licensing/license_server_client.go +++ b/pkg/licensing/license_server_client.go @@ -354,14 +354,23 @@ func (c *LicenseServerClient) parseError(resp *http.Response) error { // Try to parse structured error response from the license server. if len(body) > 0 { var parsed struct { - Code string `json:"code"` - Message string `json:"message"` - Retryable bool `json:"retryable"` + Code string `json:"code"` + LegacyCode string `json:"error"` + Message string `json:"message"` + Retryable bool `json:"retryable"` } - if json.Unmarshal(body, &parsed) == nil && parsed.Code != "" { - apiErr.Code = parsed.Code - apiErr.Message = parsed.Message - apiErr.Retryable = parsed.Retryable + if json.Unmarshal(body, &parsed) == nil { + code := strings.TrimSpace(parsed.Code) + if code == "" { + code = strings.TrimSpace(parsed.LegacyCode) + } + if code != "" { + apiErr.Code = code + if strings.TrimSpace(parsed.Message) != "" { + apiErr.Message = parsed.Message + } + apiErr.Retryable = parsed.Retryable + } } } diff --git a/pkg/licensing/license_server_client_test.go b/pkg/licensing/license_server_client_test.go index f9df5a266..0429bb508 100644 --- a/pkg/licensing/license_server_client_test.go +++ b/pkg/licensing/license_server_client_test.go @@ -144,6 +144,43 @@ func TestClientActivate(t *testing.T) { } }) + t.Run("server returns legacy error field", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "error": "invalid_activation", + "message": "Activation key could not be redeemed", + "retryable": false, + }) + })) + defer server.Close() + + client := NewLicenseServerClient(server.URL) + _, err := client.Activate(context.Background(), ActivateInstallationRequest{ + ActivationKey: "ppk_live_bad", + }) + if err == nil { + t.Fatal("expected error") + } + + apiErr, ok := err.(*LicenseServerError) + if !ok { + t.Fatalf("expected *LicenseServerError, got %T", err) + } + if apiErr.StatusCode != 400 { + t.Errorf("StatusCode = %d, want 400", apiErr.StatusCode) + } + if apiErr.Code != "invalid_activation" { + t.Errorf("Code = %q, want invalid_activation", apiErr.Code) + } + if apiErr.Message != "Activation key could not be redeemed" { + t.Errorf("Message = %q, want Activation key could not be redeemed", apiErr.Message) + } + if apiErr.Retryable { + t.Error("expected Retryable=false for 400") + } + }) + t.Run("server error is retryable", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) diff --git a/scripts/release_control/governance_stage_guard.py b/scripts/release_control/governance_stage_guard.py index af71a8524..c4b2cc2e8 100644 --- a/scripts/release_control/governance_stage_guard.py +++ b/scripts/release_control/governance_stage_guard.py @@ -56,6 +56,9 @@ def git_diff_name_only(*args: str) -> list[str]: def is_worktree_sensitive_governance_path(path: str) -> bool: if path in STAGED_EXECUTION_EXACT_FILES: return False + filename = path.rsplit("/", 1)[-1] + if filename.endswith("_test.py") or filename.endswith("_test.go"): + return False if path in WORKTREE_SENSITIVE_EXACT_FILES: return True return any(path.startswith(prefix) for prefix in WORKTREE_SENSITIVE_PREFIXES) diff --git a/scripts/release_control/governance_stage_guard_test.py b/scripts/release_control/governance_stage_guard_test.py index 1ca468567..fcf6c7c1e 100644 --- a/scripts/release_control/governance_stage_guard_test.py +++ b/scripts/release_control/governance_stage_guard_test.py @@ -28,12 +28,21 @@ class GovernanceStageGuardTest(unittest.TestCase): def test_is_worktree_sensitive_governance_path_matches_expected_scope(self) -> None: self.assertTrue(is_worktree_sensitive_governance_path(".husky/pre-commit")) self.assertTrue(is_worktree_sensitive_governance_path("docs/release-control/control_plane.json")) - self.assertTrue(is_worktree_sensitive_governance_path("internal/repoctl/canonical_development_protocol_test.go")) self.assertTrue(is_worktree_sensitive_governance_path("scripts/release_control/status_audit.py")) + self.assertFalse( + is_worktree_sensitive_governance_path( + "internal/repoctl/canonical_development_protocol_test.go" + ) + ) self.assertFalse(is_worktree_sensitive_governance_path(".github/workflows/canonical-governance.yml")) self.assertFalse(is_worktree_sensitive_governance_path("docs/release-control/internal/CONTROL_PLANE.md")) self.assertFalse(is_worktree_sensitive_governance_path("docs/release-control/v6/internal/status.json")) self.assertFalse(is_worktree_sensitive_governance_path("docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md")) + self.assertFalse( + is_worktree_sensitive_governance_path( + "scripts/release_control/subsystem_lookup_test.py" + ) + ) self.assertFalse( is_worktree_sensitive_governance_path( "scripts/release_control/release_promotion_policy_test.py" @@ -48,7 +57,9 @@ class GovernanceStageGuardTest(unittest.TestCase): "docs/release-control/control_plane.json", "docs/release-control/v6/internal/status.json", "docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md", + "internal/repoctl/canonical_development_protocol_test.go", "scripts/release_control/release_promotion_policy_test.py", + "scripts/release_control/subsystem_lookup_test.py", "internal/api/slo.go", "scripts/release_control/status_audit.py", ] diff --git a/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts b/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts index ec0b36b8f..cde82f1d1 100644 --- a/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts +++ b/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts @@ -14,9 +14,16 @@ const PURCHASE_RETURN_URL = `${DEV_SERVER_URL}/auth/license-purchase-activate`; const PORTAL_HANDOFF_ID = "cph_checkout_return"; const ACTIVATED_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/plan?intent=max_monitored_systems&purchase=activated`; const CANCELLED_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/plan?intent=max_monitored_systems&purchase=cancelled`; +const EXPIRED_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/plan?intent=max_monitored_systems&purchase=expired`; +const FAILED_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/plan?intent=max_monitored_systems&purchase=failed`; const FINAL_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/plan?intent=max_monitored_systems`; const USAGE_BILLING_URL = `${DEV_SERVER_URL}/settings/system/billing/usage`; +const RECOVERY_BILLING_HREF = + "/settings/system/billing/plan?intent=max_monitored_systems&details=recovery"; +const RECOVERY_BILLING_URL = `${DEV_SERVER_URL}${RECOVERY_BILLING_HREF}`; const PURCHASE_RETURN_TOKEN = "prt_signed_checkout_return"; +const CHECKOUT_SESSION_ID = "cs_upgrade_return"; +const MONITORED_SYSTEM_FEATURE = "max_monitored_systems"; const MONITORED_SYSTEM_ENTITLEMENTS = { capabilities: [], @@ -108,6 +115,184 @@ function escapeAttribute(value: string): string { .replaceAll(">", ">"); } +function buildPortalHandoffUrl(portalHandoffID = PORTAL_HANDOFF_ID): string { + return `${PULSE_ACCOUNT_PORTAL_URL}?portal_handoff_id=${portalHandoffID}`; +} + +function buildPurchaseReturnUrl(options: { + sessionID?: string; + portalHandoffID?: string; + feature?: string; + purchaseReturnToken?: string; +} = {}): string { + const { + sessionID = CHECKOUT_SESSION_ID, + portalHandoffID = PORTAL_HANDOFF_ID, + feature = MONITORED_SYSTEM_FEATURE, + purchaseReturnToken = PURCHASE_RETURN_TOKEN, + } = options; + const url = new URL(PURCHASE_RETURN_URL); + url.searchParams.set("session_id", sessionID); + url.searchParams.set("portal_handoff_id", portalHandoffID); + url.searchParams.set("feature", feature); + url.searchParams.set("purchase_return_token", purchaseReturnToken); + return url.toString(); +} + +function renderTimedRedirectPage(title: string, body: string, redirectUrl: string): string { + return ( + "" + + `

${title}

` + + `

${body}

` + + `` + + "" + ); +} + +function renderActivationBridgePage(options: { + sessionID?: string; + portalHandoffID?: string; + feature?: string; + purchaseReturnToken?: string; + title?: string; +} = {}): string { + const { + sessionID = CHECKOUT_SESSION_ID, + portalHandoffID = PORTAL_HANDOFF_ID, + feature = MONITORED_SYSTEM_FEATURE, + purchaseReturnToken = PURCHASE_RETURN_TOKEN, + title = "Finalizing Pulse Pro upgrade", + } = options; + return ( + "" + + `

${title}

` + + `
` + + `` + + `` + + `` + + `` + + "
" + + '' + + "" + ); +} + +function renderActivationCompletionPage(title: string, redirectUrl: string): string { + return ( + "" + + `

${title}

` + + `` + + "" + ); +} + +async function configurePurchaseStartRoute( + context: BrowserContext, + onRequest?: (requestUrl: URL) => void, +) { + await context.route(`${PURCHASE_START_URL}**`, async (route) => { + const requestUrl = new URL(route.request().url()); + onRequest?.(requestUrl); + expect(requestUrl.searchParams.get("feature")).toBe(MONITORED_SYSTEM_FEATURE); + await route.fulfill({ + status: 303, + headers: { + location: buildPortalHandoffUrl(), + }, + body: "", + }); + }); +} + +async function configurePortalRedirectRoute( + context: BrowserContext, + options: { + title?: string; + body: string; + redirectUrl: string; + }, +) { + await context.route(`${PULSE_ACCOUNT_PORTAL_URL}**`, async (route) => { + const requestUrl = new URL(route.request().url()); + expect(requestUrl.searchParams.get("service")).toBeNull(); + expect(requestUrl.searchParams.get("feature")).toBeNull(); + expect(requestUrl.searchParams.get("return_url")).toBeNull(); + expect(requestUrl.searchParams.get("purchase_handoff_url")).toBeNull(); + expect(requestUrl.searchParams.get("portal_handoff_id")).toBe(PORTAL_HANDOFF_ID); + expect(requestUrl.searchParams.get("checkout_intent_id")).toBeNull(); + + await route.fulfill({ + status: 200, + contentType: "text/html", + body: renderTimedRedirectPage( + options.title ?? "Pulse Account", + options.body, + options.redirectUrl, + ), + }); + }); +} + +async function configurePurchaseReturnRoute( + context: BrowserContext, + options: { + sessionID?: string; + portalHandoffID?: string; + feature?: string; + purchaseReturnToken?: string; + bridgeTitle?: string; + completionTitle: string; + finalRedirectUrl: string; + }, +) { + const { + sessionID = CHECKOUT_SESSION_ID, + portalHandoffID = PORTAL_HANDOFF_ID, + feature = MONITORED_SYSTEM_FEATURE, + purchaseReturnToken = PURCHASE_RETURN_TOKEN, + bridgeTitle, + completionTitle, + finalRedirectUrl, + } = options; + + await context.route(`${PURCHASE_RETURN_URL}**`, async (route) => { + const request = route.request(); + if (request.method() === "GET") { + const requestUrl = new URL(request.url()); + expect(requestUrl.searchParams.get("session_id")).toBe(sessionID); + expect(requestUrl.searchParams.get("portal_handoff_id")).toBe(portalHandoffID); + expect(requestUrl.searchParams.get("feature")).toBe(feature); + expect(requestUrl.searchParams.get("purchase_return_token")).toBe( + purchaseReturnToken, + ); + await route.fulfill({ + status: 200, + contentType: "text/html", + body: renderActivationBridgePage({ + sessionID, + portalHandoffID, + feature, + purchaseReturnToken, + title: bridgeTitle, + }), + }); + return; + } + + expect(request.method()).toBe("POST"); + const formData = new URLSearchParams(request.postData() || ""); + expect(formData.get("session_id")).toBe(sessionID); + expect(formData.get("portal_handoff_id")).toBe(portalHandoffID); + expect(formData.get("feature")).toBe(feature); + expect(formData.get("purchase_return_token")).toBe(purchaseReturnToken); + await route.fulfill({ + status: 200, + contentType: "text/html", + body: renderActivationCompletionPage(completionTitle, finalRedirectUrl), + }); + }); +} + async function configureBillingFixtures(context: BrowserContext, page: Page) { await page.addInitScript(() => { localStorage.setItem("pulse_whats_new_v2_shown", "true"); @@ -187,85 +372,16 @@ test.describe("Self-hosted upgrade return flow", () => { await configureBillingFixtures(context, page); let purchaseStartURL = ""; - await context.route(`${PURCHASE_START_URL}**`, async (route) => { - const requestUrl = new URL(route.request().url()); + await configurePurchaseStartRoute(context, (requestUrl) => { purchaseStartURL = requestUrl.toString(); - expect(requestUrl.searchParams.get("feature")).toBe( - "max_monitored_systems", - ); - await route.fulfill({ - status: 303, - headers: { - location: `${PULSE_ACCOUNT_PORTAL_URL}?portal_handoff_id=${PORTAL_HANDOFF_ID}`, - }, - body: "", - }); }); - - await context.route(`${PULSE_ACCOUNT_PORTAL_URL}**`, async (route) => { - const requestUrl = new URL(route.request().url()); - expect(requestUrl.searchParams.get("service")).toBeNull(); - expect(requestUrl.searchParams.get("feature")).toBeNull(); - expect(requestUrl.searchParams.get("return_url")).toBeNull(); - expect(requestUrl.searchParams.get("purchase_handoff_url")).toBeNull(); - expect(requestUrl.searchParams.get("portal_handoff_id")).toBe( - PORTAL_HANDOFF_ID, - ); - expect(requestUrl.searchParams.get("checkout_intent_id")).toBeNull(); - - await route.fulfill({ - status: 200, - contentType: "text/html", - body: - "" + - "

Pulse Account

" + - "

Checkout complete. Returning to Pulse Pro.

" + - `` + - "", - }); + await configurePortalRedirectRoute(context, { + body: "Checkout complete. Returning to Pulse Pro.", + redirectUrl: buildPurchaseReturnUrl(), }); - - await context.route(`${PURCHASE_RETURN_URL}**`, async (route) => { - const request = route.request(); - if (request.method() === "GET") { - const requestUrl = new URL(request.url()); - expect(requestUrl.searchParams.get("session_id")).toBe( - "cs_upgrade_return", - ); - expect(requestUrl.searchParams.get("purchase_return_token")).toBe( - PURCHASE_RETURN_TOKEN, - ); - await route.fulfill({ - status: 200, - contentType: "text/html", - body: - "" + - "

Finalizing Pulse Pro upgrade

" + - `
` + - '' + - `` + - "
" + - '' + - "", - }); - return; - } - - expect(request.method()).toBe("POST"); - const formData = new URLSearchParams(request.postData() || ""); - expect(formData.get("session_id")).toBe("cs_upgrade_return"); - expect(formData.get("purchase_return_token")).toBe(PURCHASE_RETURN_TOKEN); - await route.fulfill({ - status: 200, - contentType: "text/html", - body: - "" + - "

Pulse Pro activated

" + - `` + - "", - }); + await configurePurchaseReturnRoute(context, { + completionTitle: "Pulse Pro activated", + finalRedirectUrl: ACTIVATED_BILLING_URL, }); await openMonitoredSystemUpgradeArrival(page); @@ -282,12 +398,7 @@ test.describe("Self-hosted upgrade return flow", () => { .poll(() => purchaseStartURL) .toBe(`${PURCHASE_START_URL}?feature=max_monitored_systems`); - await page.goto( - `${PULSE_ACCOUNT_PORTAL_URL}?portal_handoff_id=${PORTAL_HANDOFF_ID}`, - { - waitUntil: "domcontentloaded", - }, - ); + await page.goto(buildPortalHandoffUrl(), { waitUntil: "domcontentloaded" }); await expect( page.getByRole("heading", { name: "Pulse Account" }), ).toBeVisible(); @@ -322,29 +433,10 @@ test.describe("Self-hosted upgrade return flow", () => { const context = page.context(); await configureBillingFixtures(context, page); - await context.route(`${PURCHASE_START_URL}**`, async (route) => { - await route.fulfill({ - status: 303, - headers: { - location: `${PULSE_ACCOUNT_PORTAL_URL}?portal_handoff_id=${PORTAL_HANDOFF_ID}`, - }, - body: "", - }); - }); - - await context.route(`${PULSE_ACCOUNT_PORTAL_URL}**`, async (route) => { - await route.fulfill({ - status: 200, - contentType: "text/html", - body: - "" + - "

Pulse Account

" + - "

Checkout cancelled. Returning to Pulse Pro.

" + - `` + - "", - }); + await configurePurchaseStartRoute(context); + await configurePortalRedirectRoute(context, { + body: "Checkout cancelled. Returning to Pulse Pro.", + redirectUrl: CANCELLED_BILLING_URL, }); await openMonitoredSystemUpgradeArrival(page); @@ -352,12 +444,7 @@ test.describe("Self-hosted upgrade return flow", () => { const comparePlansLink = page.getByRole("link", { name: "Compare plans" }); await comparePlansLink.click(); - await page.goto( - `${PULSE_ACCOUNT_PORTAL_URL}?portal_handoff_id=${PORTAL_HANDOFF_ID}`, - { - waitUntil: "domcontentloaded", - }, - ); + await page.goto(buildPortalHandoffUrl(), { waitUntil: "domcontentloaded" }); await expect(page).toHaveURL(CANCELLED_BILLING_URL); await expect(page.getByText("Checkout cancelled", { exact: true })).toBeVisible(); @@ -366,4 +453,116 @@ test.describe("Self-hosted upgrade return flow", () => { `${PURCHASE_START_PATH}?feature=max_monitored_systems`, ); }); + + test("returns an already-consumed checkout back to the activated billing state", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name.startsWith("mobile-"), + "Desktop-only billing continuity", + ); + + const context = page.context(); + await configureBillingFixtures(context, page); + + await configurePurchaseStartRoute(context); + await configurePortalRedirectRoute(context, { + body: "Checkout complete. Returning to Pulse Pro.", + redirectUrl: buildPurchaseReturnUrl(), + }); + await configurePurchaseReturnRoute(context, { + bridgeTitle: "Confirming Pulse Pro activation state", + completionTitle: "Purchase activation already completed", + finalRedirectUrl: ACTIVATED_BILLING_URL, + }); + + await openMonitoredSystemUpgradeArrival(page); + await page.getByRole("link", { name: "Compare plans" }).click(); + await page.goto(buildPortalHandoffUrl(), { waitUntil: "domcontentloaded" }); + + await expect(page).toHaveURL(FINAL_BILLING_URL); + await expect( + page.getByText("Pulse Pro activated", { exact: true }), + ).toBeVisible(); + await expect(page.getByRole("link", { name: "Review usage" })).toHaveAttribute( + "href", + "/settings/system/billing/usage", + ); + }); + + test("returns an expired or mismatched checkout state to restart the owned upgrade flow", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name.startsWith("mobile-"), + "Desktop-only billing continuity", + ); + + const context = page.context(); + await configureBillingFixtures(context, page); + + await configurePurchaseStartRoute(context); + await configurePortalRedirectRoute(context, { + body: "Secure upgrade state expired. Returning to Pulse Pro.", + redirectUrl: buildPurchaseReturnUrl(), + }); + await configurePurchaseReturnRoute(context, { + bridgeTitle: "Verifying Pulse Pro checkout return", + completionTitle: "Upgrade return expired", + finalRedirectUrl: EXPIRED_BILLING_URL, + }); + + await openMonitoredSystemUpgradeArrival(page); + await page.getByRole("link", { name: "Compare plans" }).click(); + await page.goto(buildPortalHandoffUrl(), { waitUntil: "domcontentloaded" }); + + await expect(page).toHaveURL(EXPIRED_BILLING_URL); + await expect( + page.getByText("Upgrade return expired", { exact: true }), + ).toBeVisible(); + const restartUpgradeLink = page.getByRole("link", { name: "Restart upgrade" }); + await expect(restartUpgradeLink).toHaveAttribute( + "href", + `${PURCHASE_START_PATH}?feature=max_monitored_systems`, + ); + await expect(restartUpgradeLink).toHaveAttribute("target", "_blank"); + }); + + test("returns local activation failures to the billing recovery entry point", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name.startsWith("mobile-"), + "Desktop-only billing continuity", + ); + + const context = page.context(); + await configureBillingFixtures(context, page); + + await configurePurchaseStartRoute(context); + await configurePortalRedirectRoute(context, { + body: "Checkout complete, but Pulse Pro needs local recovery.", + redirectUrl: buildPurchaseReturnUrl(), + }); + await configurePurchaseReturnRoute(context, { + bridgeTitle: "Finishing local Pulse Pro activation", + completionTitle: "Activation needs attention", + finalRedirectUrl: FAILED_BILLING_URL, + }); + + await openMonitoredSystemUpgradeArrival(page); + await page.getByRole("link", { name: "Compare plans" }).click(); + await page.goto(buildPortalHandoffUrl(), { waitUntil: "domcontentloaded" }); + + await expect(page).toHaveURL(FAILED_BILLING_URL); + await expect( + page.getByText("Activation needs attention", { exact: true }), + ).toBeVisible(); + const recoveryLink = page.getByRole("link", { name: "Open recovery" }); + await expect(recoveryLink).toHaveAttribute("href", RECOVERY_BILLING_HREF); + await expect(recoveryLink).not.toHaveAttribute("target", "_blank"); + await recoveryLink.click(); + await expect(page).toHaveURL(RECOVERY_BILLING_URL); + await expect(page.locator("#pulse-pro-recovery")).toBeVisible(); + }); });