From 8355335e082d46d0bfa72f6ec588a907f819f125 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 7 Jul 2026 21:46:07 +0100 Subject: [PATCH] Clarify v5 migration recovery states --- docs/UPGRADE_v6.md | 7 + .../v6/internal/subsystems/agent-lifecycle.md | 7 + .../v6/internal/subsystems/api-contracts.md | 10 ++ .../v6/internal/subsystems/cloud-paid.md | 11 ++ .../internal/subsystems/storage-recovery.md | 7 + .../src/api/__tests__/license.test.ts | 25 +++ frontend-modern/src/api/license.ts | 1 + .../Settings/ProLicensePlanSection.tsx | 10 +- .../__tests__/ProLicensePanel.test.tsx | 61 +++++++ .../__tests__/licensePresentation.test.ts | 47 +++++ .../src/utils/licensePresentation.ts | 15 +- internal/api/contract_test.go | 26 +++ internal/api/licensing_bridge.go | 7 + internal/api/licensing_handlers.go | 12 +- .../licensing_handlers_auto_migrate_test.go | 45 +++++ internal/api/licensing_legacy_retry_test.go | 59 +++++++ pkg/licensing/commercial_migration.go | 65 ++++++- pkg/licensing/commercial_migration_test.go | 59 +++++++ pkg/licensing/license_server_client.go | 36 +++- pkg/licensing/license_server_client_test.go | 39 +++++ .../release_control/subsystem_lookup_test.py | 4 +- .../tests/12-v5-commercial-migration.spec.ts | 164 ++++++++++++++++-- 22 files changed, 679 insertions(+), 38 deletions(-) diff --git a/docs/UPGRADE_v6.md b/docs/UPGRADE_v6.md index aef4efa30..a9e4da7d5 100644 --- a/docs/UPGRADE_v6.md +++ b/docs/UPGRADE_v6.md @@ -157,6 +157,13 @@ canonical, but the retired rc.1 through rc.5 `/infrastructure`, `/workloads`, - If you are upgrading directly from v5, start from the familiar platform pages rather than looking for the temporary unified pages from early v6 RCs. +### Configuration Compatibility + +Pulse v6 honors the legacy `PORT` environment variable as a deprecated fallback +only when `FRONTEND_PORT` is unset, so existing installs keep their listener +port after upgrade. Move deployments to `FRONTEND_PORT`; when both variables +are set, `FRONTEND_PORT` wins. + ### API Changes Unified Resources is now the canonical model and endpoint family: diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 21957d714..f58ddff01 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -97,6 +97,13 @@ that binary, not separate customer-facing agent products. ## Shared Boundaries +Commercial v5-to-v6 migration retry state may pass through `internal/api/` +handlers that agent-lifecycle also references, but the ownership remains +API/cloud-paid. Agent lifecycle surfaces may observe paid-migration posture for +upgrade continuity, but they must not reinterpret `commercial_migration` +reasons, reset `first_failed_at`, change the license-server retry cadence, or +turn blocked license egress into an agent update or enrollment state. + `/api/connections` command-policy comparison is lifecycle-adjacent fleet truth. The desired side is the effective runtime config served to the agent after token scope and binding checks, not the unsanitized profile desire. If a diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 2f5ebf034..d71b00742 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -139,6 +139,16 @@ product API routes free of maintainer commercial analytics. ## Shared Boundaries +Commercial migration payloads are a shared API/cloud-paid contract. The +license-server client must preserve canonical v6 nested error-envelope codes +such as `RENEWED_KEY_AVAILABLE`, and commercial posture payloads may carry +`commercial_migration.first_failed_at` so the backend can distinguish a short +transport outage from sustained blocked egress without changing retry cadence. +Browser API types and presentation helpers must treat `exchange_stale_key` plus +`retrieve_current_key` and `exchange_connectivity_required` plus +`allow_license_egress` as explicit migration states rather than inferring them +from HTTP status alone. + `GET /api/connections` consumes the agent desired-config contract for fleet governance. When it derives `fleet.commandPolicy`, `fleet.configDrift`, and rollout state, it must compare the agent-applied report with the effective diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 9d5e56704..32f34c71a 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -257,6 +257,17 @@ avoids a cloud-control-plane report data path across clients. pending must self-retry in the background with backoff for the life of the process so a transient license-server or DNS failure at first boot never strands a paying upgrader on Community until a manual restart. + Signature-valid v5 JWTs that are expired beyond the v5 grace window but + correspond to a newer retrievable server-side key or live entitlement must + classify as terminal stale-key recovery (`exchange_stale_key` with + `retrieve_current_key`) rather than generic invalid-key failure; malformed, + signature-invalid, and truly lapsed keys must keep the generic terminal + rejection path. Transport-level legacy-exchange failures must preserve the + first continuous failure timestamp on `commercial_migration.first_failed_at` + and, after 24 hours, keep retrying while surfacing + `exchange_connectivity_required` with the outbound + `license.pulserelay.pro` connectivity policy instead of rendering ordinary + pending copy forever. 6. `internal/api/licensing_legacy_retry.go` shared with `api-contracts`: the background legacy-exchange retry loop carries both API payload contract and cloud-paid entitlement boundary ownership. 7. `internal/api/payments_webhook_handlers.go` shared with `api-contracts`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership. 8. `internal/api/public_signup_handlers.go` shared with `api-contracts`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index d0656a3cb..bce94262d 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -63,6 +63,13 @@ state. ## Extension Points +Commercial v5-to-v6 migration posture may be carried by `internal/api/` +handlers that storage/recovery references for setup or support-adjacent flows, +but it remains API/cloud-paid state. Storage and recovery surfaces may point +operators at the v6 upgrade guide when license egress is blocked, but they must +not reinterpret `commercial_migration` as backup coverage, restore readiness, +or storage-health evidence, and must not mutate `first_failed_at`. + Mobile onboarding reads exposed through `internal/api/onboarding_handlers.go` are storage/recovery-adjacent only as hosted recovery/support handoff surfaces. Recovery code may consume the API-owned diff --git a/frontend-modern/src/api/__tests__/license.test.ts b/frontend-modern/src/api/__tests__/license.test.ts index a6b41f303..070112a0b 100644 --- a/frontend-modern/src/api/__tests__/license.test.ts +++ b/frontend-modern/src/api/__tests__/license.test.ts @@ -70,6 +70,31 @@ describe('LicenseAPI', () => { }); }); + it('preserves commercial migration timing fields from entitlements', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + tier: 'free', + subscription_state: 'expired', + capabilities: [], + limits: [], + upgrade_reasons: [], + commercial_migration: { + state: 'pending', + reason: 'exchange_connectivity_required', + recommended_action: 'allow_license_egress', + first_failed_at: 1_700_000_000, + }, + }); + + const result = await LicenseAPI.getCommercialEntitlements(); + + expect(result.commercial_migration).toMatchObject({ + state: 'pending', + reason: 'exchange_connectivity_required', + recommended_action: 'allow_license_egress', + first_failed_at: 1_700_000_000, + }); + }); + it('reads commercial posture from the public-safe commercial endpoint', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ tier: 'pro', diff --git a/frontend-modern/src/api/license.ts b/frontend-modern/src/api/license.ts index 30c9dcf31..fb1fd22dd 100644 --- a/frontend-modern/src/api/license.ts +++ b/frontend-modern/src/api/license.ts @@ -52,6 +52,7 @@ export interface CommercialMigrationStatus { state?: string; reason?: string; recommended_action?: string; + first_failed_at?: number; } // Mirrors internal/api/subscription_entitlements.go:RuntimeCapabilitiesPayload diff --git a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx index 345b06169..ea793915a 100644 --- a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx @@ -1,6 +1,7 @@ import { Component, For, Show } from 'solid-js'; import RefreshCw from 'lucide-solid/icons/refresh-cw'; import { Button, ButtonLink } from '@/components/shared/Button'; +import { InlineNotice, type InlineNoticeTone } from '@/components/shared/InlineNotice'; import { UpgradeButtonLink } from '@/components/shared/UpgradeLink'; import { licenseEntitlementsLoadError } from '@/stores/licenseEntitlements'; import { @@ -104,6 +105,9 @@ const formatDate = (value?: string | null) => { return date.toLocaleDateString(); }; +const commercialMigrationNoticeTone = (notice: Notice): InlineNoticeTone => + notice.tone.includes('red-') ? 'danger' : 'warning'; + const statusStateClass = (state: 'active' | 'partial' | 'missing') => { switch (state) { case 'active': @@ -385,10 +389,10 @@ export const ProLicensePlanSection: Component = (pro {(notice) => ( -
+

{notice().title}

-

{notice().body}

-
+

{notice().body}

+ )}
diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 6f7b066cd..431b0c798 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -1527,6 +1527,67 @@ describe('ProLicensePanel', () => { ).not.toBeInTheDocument(); }); + it('shows retrieve-license guidance when a v5 key has been superseded', async () => { + mockEntitlements = { + capabilities: [], + limits: [], + subscription_state: 'expired', + upgrade_reasons: [], + tier: 'free', + trial_eligible: false, + commercial_migration: { + source: 'v5_license', + state: 'failed', + reason: 'exchange_stale_key', + recommended_action: 'retrieve_current_key', + }, + }; + + renderPanel(); + + const title = screen.getByText('v5 license migration needs attention'); + expect(title).toBeInTheDocument(); + expect(title.closest('.border-red-300')).not.toBeNull(); + expect(screen.getByText(/superseded by a renewal/i)).toBeInTheDocument(); + expect(screen.getByText(/pulserelay\.pro\/retrieve-license/i)).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /start 14-day pro trial/i }), + ).not.toBeInTheDocument(); + }); + + it('shows blocked-egress guidance after sustained exchange transport failure', async () => { + mockEntitlements = { + capabilities: [], + limits: [], + subscription_state: 'expired', + upgrade_reasons: [], + tier: 'free', + trial_eligible: false, + commercial_migration: { + source: 'v5_license', + state: 'pending', + reason: 'exchange_connectivity_required', + recommended_action: 'allow_license_egress', + first_failed_at: 1_700_000_000, + }, + }; + + renderPanel(); + + const title = screen.getByText('v5 license migration pending'); + expect(title).toBeInTheDocument(); + expect(title.closest('.border-amber-300')).not.toBeNull(); + expect( + screen.getByText(/paid v6 features require periodic outbound HTTPS/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/allow outbound HTTPS to license\.pulserelay\.pro/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /start 14-day pro trial/i }), + ).not.toBeInTheDocument(); + }); + it('keeps Pro license split into shell, runtime, and plan owners', () => { expect(proLicensePanelSource).toContain('./useProLicensePanelState'); expect(proLicensePanelSource).toContain('sessionPresentationPolicyResolved'); diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index 2750550c6..e7a837b5f 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -205,6 +205,53 @@ describe('licensePresentation', () => { expect(notice?.body).not.toContain('still settling'); }); + it('points superseded v5 keys at current-key retrieval instead of retrying', () => { + const notice = getCommercialMigrationNotice({ + state: 'failed', + reason: 'exchange_stale_key', + recommended_action: 'retrieve_current_key', + } as never); + expect(notice).toMatchObject({ + title: 'v5 license migration needs attention', + tone: expect.stringContaining('red'), + }); + expect(notice?.body).toContain('superseded by a renewal'); + expect(notice?.body).toContain('pulserelay.pro/retrieve-license'); + expect(notice?.body).not.toContain('Retry with the original v5 Pro/Lifetime key'); + }); + + it('keeps generic rejected-key copy distinct while offering safe license retrieval', () => { + const notice = getCommercialMigrationNotice({ + state: 'failed', + reason: 'exchange_invalid', + recommended_action: 'enter_supported_v5_key', + } as never); + expect(notice).toMatchObject({ + title: 'v5 license migration needs attention', + tone: expect.stringContaining('red'), + }); + expect(notice?.body).toContain('rejected during v6 migration'); + expect(notice?.body).toContain('pulserelay.pro/retrieve-license'); + expect(notice?.body).toContain('Retry with the original v5 Pro/Lifetime key'); + }); + + it('renders sustained transport failure as blocked-egress policy, not ordinary pending', () => { + const notice = getCommercialMigrationNotice({ + state: 'pending', + reason: 'exchange_connectivity_required', + recommended_action: 'allow_license_egress', + } as never); + expect(notice).toMatchObject({ + title: 'v5 license migration pending', + tone: expect.stringContaining('amber'), + }); + expect(notice?.body).toContain('over a day'); + expect(notice?.body).toContain('Paid v6 features require periodic outbound HTTPS'); + expect(notice?.body).toContain('Core monitoring keeps running'); + expect(notice?.body).toContain('license.pulserelay.pro'); + expect(notice?.body).toContain('docs/UPGRADE_v6.md'); + }); + it('renders an unreadable persisted v5 license as terminal with re-enter-key guidance', () => { const notice = getCommercialMigrationNotice({ state: 'failed', diff --git a/frontend-modern/src/utils/licensePresentation.ts b/frontend-modern/src/utils/licensePresentation.ts index 1d4b21526..55607c31f 100644 --- a/frontend-modern/src/utils/licensePresentation.ts +++ b/frontend-modern/src/utils/licensePresentation.ts @@ -1037,6 +1037,10 @@ export const getCommercialMigrationActionText = (action?: string): string => { return 'Retry with the original v5 Pro/Lifetime key from this instance.'; case 'free_installation_slot': return 'Contact support@pulserelay.pro to release an installation you no longer use or to raise the limit.'; + case 'retrieve_current_key': + return 'Retrieve your current key at pulserelay.pro/retrieve-license and paste it here.'; + case 'allow_license_egress': + return 'Allow outbound HTTPS to license.pulserelay.pro, then Pulse will keep retrying automatically.'; default: return 'Review the plan state from this instance before trying again.'; } @@ -1060,6 +1064,10 @@ export const getCommercialMigrationNotice = ( body = 'Pulse detected a paid v5 license, but another v6 license handoff is still settling.'; break; + case 'exchange_connectivity_required': + body = + 'Pulse has not been able to reach license.pulserelay.pro for over a day. Paid v6 features require periodic outbound HTTPS to that host. Core monitoring keeps running; paid features stay on Community until connectivity is allowed. See docs/UPGRADE_v6.md for the connectivity policy.'; + break; case 'exchange_unavailable': default: break; @@ -1079,7 +1087,12 @@ export const getCommercialMigrationNotice = ( 'Pulse detected a paid v5 license, but that key is already active on its maximum number of v6 installations, so this instance cannot activate until a slot is freed.'; break; case 'exchange_invalid': - body = 'Pulse detected a paid v5 license, but that key was rejected during v6 migration.'; + body = + 'Pulse detected a paid v5 license, but that key was rejected during v6 migration. Retrieve your current key at pulserelay.pro/retrieve-license if this purchase is still active.'; + break; + case 'exchange_stale_key': + body = + 'Pulse detected a paid v5 license, but this key has been superseded by a renewal.'; break; case 'exchange_malformed': body = 'Pulse detected a v5-looking key, but it is malformed and cannot be migrated.'; diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 29d6b0a90..5174901bd 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -133,6 +133,32 @@ func TestContract_AlertDeliveryDiagnosisPayloadShape(t *testing.T) { } } +func TestContract_CommercialMigrationPayloadCarriesFailureTiming(t *testing.T) { + status := pkglicensing.CommercialMigrationStatus{ + Source: pkglicensing.CommercialMigrationSourceV5License, + State: pkglicensing.CommercialMigrationStatePending, + Reason: pkglicensing.CommercialMigrationReasonExchangeConnectivity, + RecommendedAction: pkglicensing.CommercialMigrationActionAllowLicenseEgress, + FirstFailedAt: 1_700_000_000, + } + + payload, err := json.Marshal(status) + if err != nil { + t.Fatalf("marshal commercial migration status: %v", err) + } + body := string(payload) + for _, field := range []string{ + `"state":"pending"`, + `"reason":"exchange_connectivity_required"`, + `"recommended_action":"allow_license_egress"`, + `"first_failed_at":1700000000`, + } { + if !strings.Contains(body, field) { + t.Fatalf("commercial migration payload missing %s in %s", field, body) + } + } +} + func TestContract_ReportSchedulePayloadShape(t *testing.T) { nextRun := time.Date(2026, 7, 8, 6, 0, 0, 0, time.UTC) schedule := config.ReportSchedule{ diff --git a/internal/api/licensing_bridge.go b/internal/api/licensing_bridge.go index 0191fb3b3..fa721c570 100644 --- a/internal/api/licensing_bridge.go +++ b/internal/api/licensing_bridge.go @@ -100,6 +100,13 @@ func cloneCommercialMigrationStatusFromLicensing(state *commercialMigrationStatu return pkglicensing.CloneCommercialMigrationStatus(state) } +func applyCommercialMigrationFailureTimingFromLicensing( + status, previous *commercialMigrationStatusModel, + now time.Time, +) *commercialMigrationStatusModel { + return pkglicensing.ApplyCommercialMigrationFailureTiming(status, previous, now) +} + func classifyLegacyExchangeErrorFromLicensing(err error) *commercialMigrationStatusModel { return pkglicensing.ClassifyLegacyExchangeError(err) } diff --git a/internal/api/licensing_handlers.go b/internal/api/licensing_handlers.go index ed31af7ad..d6440fcb7 100644 --- a/internal/api/licensing_handlers.go +++ b/internal/api/licensing_handlers.go @@ -70,6 +70,8 @@ type LicenseHandlers struct { legacyExchangeRetries sync.Map // map[string]struct{} // legacyExchangeRetrySchedule overrides the retry backoff in tests. legacyExchangeRetrySchedule []time.Duration + // commercialMigrationNow overrides migration timing in tests. + commercialMigrationNow func() time.Time } // NewLicenseHandlers creates a new license handlers instance. @@ -799,7 +801,15 @@ func (h *LicenseHandlers) setCommercialMigrationState(orgID string, status *comm } } - existing.CommercialMigration = cloneCommercialMigrationStatusFromLicensing(status) + now := time.Now + if h.commercialMigrationNow != nil { + now = h.commercialMigrationNow + } + existing.CommercialMigration = applyCommercialMigrationFailureTimingFromLicensing( + status, + existing.CommercialMigration, + now(), + ) return billingStore.SaveBillingState(orgID, existing) } diff --git a/internal/api/licensing_handlers_auto_migrate_test.go b/internal/api/licensing_handlers_auto_migrate_test.go index ea70912c7..fb0bbb421 100644 --- a/internal/api/licensing_handlers_auto_migrate_test.go +++ b/internal/api/licensing_handlers_auto_migrate_test.go @@ -151,6 +151,51 @@ func TestGetTenantComponents_AutoExchangesPersistedLegacyJWT(t *testing.T) { handlers.StopAllBackgroundLoops() } +func TestSetCommercialMigrationState_AutoMigrateEscalatesSustainedTransportFailure(t *testing.T) { + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + handlers := NewLicenseHandlers(mtp, false) + + now := time.Unix(1_700_000_000, 0) + handlers.commercialMigrationNow = func() time.Time { return now } + if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{ + Source: pkglicensing.CommercialMigrationSourceV5License, + State: pkglicensing.CommercialMigrationStatePending, + Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable, + RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation, + }); err != nil { + t.Fatalf("set initial migration state: %v", err) + } + + now = now.Add(time.Duration(pkglicensing.CommercialMigrationSustainedExchangeUnavailableSeconds+1) * time.Second) + if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{ + Source: pkglicensing.CommercialMigrationSourceV5License, + State: pkglicensing.CommercialMigrationStatePending, + Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable, + RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation, + }); err != nil { + t.Fatalf("set sustained migration state: %v", err) + } + + store := config.NewFileBillingStore(baseDir) + state, err := store.GetBillingState("default") + if err != nil { + t.Fatalf("GetBillingState sustained: %v", err) + } + if state == nil || state.CommercialMigration == nil { + t.Fatal("expected sustained commercial migration state") + } + if state.CommercialMigration.FirstFailedAt != 1_700_000_000 { + t.Fatalf("sustained first_failed_at=%d want 1700000000", state.CommercialMigration.FirstFailedAt) + } + if state.CommercialMigration.Reason != pkglicensing.CommercialMigrationReasonExchangeConnectivity { + t.Fatalf("sustained reason=%q want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonExchangeConnectivity) + } + if state.CommercialMigration.RecommendedAction != pkglicensing.CommercialMigrationActionAllowLicenseEgress { + t.Fatalf("sustained action=%q want %q", state.CommercialMigration.RecommendedAction, pkglicensing.CommercialMigrationActionAllowLicenseEgress) + } +} + func TestGetTenantComponents_SkipsExchange_WhenActivationStateExists(t *testing.T) { t.Setenv("PULSE_LICENSE_DEV_MODE", "true") diff --git a/internal/api/licensing_legacy_retry_test.go b/internal/api/licensing_legacy_retry_test.go index 53ebf33ce..6458a2c35 100644 --- a/internal/api/licensing_legacy_retry_test.go +++ b/internal/api/licensing_legacy_retry_test.go @@ -286,3 +286,62 @@ func TestGetTenantComponents_SurfacesUnreadablePersistedLicense(t *testing.T) { t.Fatalf("commercial_migration.reason=%q, want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonPersistedUnreadable) } } + +func TestSetCommercialMigrationState_EscalatesSustainedTransportFailure(t *testing.T) { + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + handlers := NewLicenseHandlers(mtp, false) + + now := time.Unix(1_700_000_000, 0) + handlers.commercialMigrationNow = func() time.Time { return now } + if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{ + Source: pkglicensing.CommercialMigrationSourceV5License, + State: pkglicensing.CommercialMigrationStatePending, + Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable, + RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation, + }); err != nil { + t.Fatalf("set initial migration state: %v", err) + } + + store := config.NewFileBillingStore(baseDir) + state, err := store.GetBillingState("default") + if err != nil { + t.Fatalf("GetBillingState initial: %v", err) + } + if state == nil || state.CommercialMigration == nil { + t.Fatal("expected initial commercial migration state") + } + if state.CommercialMigration.FirstFailedAt != now.Unix() { + t.Fatalf("first_failed_at=%d want %d", state.CommercialMigration.FirstFailedAt, now.Unix()) + } + if state.CommercialMigration.Reason != pkglicensing.CommercialMigrationReasonExchangeUnavailable { + t.Fatalf("initial reason=%q want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonExchangeUnavailable) + } + + now = now.Add(time.Duration(pkglicensing.CommercialMigrationSustainedExchangeUnavailableSeconds+1) * time.Second) + if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{ + Source: pkglicensing.CommercialMigrationSourceV5License, + State: pkglicensing.CommercialMigrationStatePending, + Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable, + RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation, + }); err != nil { + t.Fatalf("set sustained migration state: %v", err) + } + + state, err = store.GetBillingState("default") + if err != nil { + t.Fatalf("GetBillingState sustained: %v", err) + } + if state == nil || state.CommercialMigration == nil { + t.Fatal("expected sustained commercial migration state") + } + if state.CommercialMigration.FirstFailedAt != 1_700_000_000 { + t.Fatalf("sustained first_failed_at=%d want 1700000000", state.CommercialMigration.FirstFailedAt) + } + if state.CommercialMigration.Reason != pkglicensing.CommercialMigrationReasonExchangeConnectivity { + t.Fatalf("sustained reason=%q want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonExchangeConnectivity) + } + if state.CommercialMigration.RecommendedAction != pkglicensing.CommercialMigrationActionAllowLicenseEgress { + t.Fatalf("sustained action=%q want %q", state.CommercialMigration.RecommendedAction, pkglicensing.CommercialMigrationActionAllowLicenseEgress) + } +} diff --git a/pkg/licensing/commercial_migration.go b/pkg/licensing/commercial_migration.go index 6971dbf41..741f46bc4 100644 --- a/pkg/licensing/commercial_migration.go +++ b/pkg/licensing/commercial_migration.go @@ -3,6 +3,7 @@ package licensing import ( "errors" "strings" + "time" ) type CommercialMigrationSource string @@ -25,13 +26,19 @@ const ( CommercialMigrationReasonExchangeRevoked CommercialMigrationReason = "exchange_revoked" CommercialMigrationReasonExchangeNonMigratable CommercialMigrationReason = "exchange_non_migratable" CommercialMigrationReasonExchangeUnsupportedKey CommercialMigrationReason = "exchange_unsupported" + CommercialMigrationReasonExchangeStaleKey CommercialMigrationReason = "exchange_stale_key" + CommercialMigrationReasonExchangeConnectivity CommercialMigrationReason = "exchange_connectivity_required" CommercialMigrationActionRetryActivation CommercialMigrationAction = "retry_activation" CommercialMigrationActionUseV6Activation CommercialMigrationAction = "use_v6_activation_key" CommercialMigrationActionEnterSupportedV5 CommercialMigrationAction = "enter_supported_v5_key" CommercialMigrationActionFreeInstallationSlot CommercialMigrationAction = "free_installation_slot" + CommercialMigrationActionRetrieveCurrentKey CommercialMigrationAction = "retrieve_current_key" + CommercialMigrationActionAllowLicenseEgress CommercialMigrationAction = "allow_license_egress" ) +const CommercialMigrationSustainedExchangeUnavailableSeconds int64 = 24 * 60 * 60 + // CommercialMigrationStatus is the explicit v6-owned contract for unresolved // paid-license migrations entering from pre-v6 commercial state. type CommercialMigrationStatus struct { @@ -39,6 +46,7 @@ type CommercialMigrationStatus struct { State CommercialMigrationState `json:"state,omitempty"` Reason CommercialMigrationReason `json:"reason,omitempty"` RecommendedAction CommercialMigrationAction `json:"recommended_action,omitempty"` + FirstFailedAt int64 `json:"first_failed_at,omitempty"` } func (s *CommercialMigrationStatus) Active() bool { @@ -55,6 +63,9 @@ func NormalizeCommercialMigrationStatus(status *CommercialMigrationStatus) *Comm normalized.State = CommercialMigrationState(strings.TrimSpace(string(normalized.State))) normalized.Reason = CommercialMigrationReason(strings.TrimSpace(string(normalized.Reason))) normalized.RecommendedAction = CommercialMigrationAction(strings.TrimSpace(string(normalized.RecommendedAction))) + if normalized.FirstFailedAt < 0 { + normalized.FirstFailedAt = 0 + } switch normalized.State { case CommercialMigrationStatePending, CommercialMigrationStateFailed: @@ -85,6 +96,47 @@ func CloneCommercialMigrationStatus(status *CommercialMigrationStatus) *Commerci return &cloned } +func ApplyCommercialMigrationFailureTiming(status, previous *CommercialMigrationStatus, now time.Time) *CommercialMigrationStatus { + normalized := NormalizeCommercialMigrationStatus(status) + if normalized == nil { + return nil + } + if !commercialMigrationTransportUnavailableReason(normalized.Reason) || normalized.State != CommercialMigrationStatePending { + normalized.FirstFailedAt = 0 + return normalized + } + if now.IsZero() { + now = time.Now() + } + + firstFailedAt := normalized.FirstFailedAt + if prev := NormalizeCommercialMigrationStatus(previous); prev != nil && + prev.State == CommercialMigrationStatePending && + commercialMigrationTransportUnavailableReason(prev.Reason) && + prev.FirstFailedAt > 0 { + firstFailedAt = prev.FirstFailedAt + } + if firstFailedAt <= 0 { + firstFailedAt = now.Unix() + } + + normalized.FirstFailedAt = firstFailedAt + if now.Unix()-firstFailedAt >= CommercialMigrationSustainedExchangeUnavailableSeconds { + normalized.Reason = CommercialMigrationReasonExchangeConnectivity + normalized.RecommendedAction = CommercialMigrationActionAllowLicenseEgress + } + return normalized +} + +func commercialMigrationTransportUnavailableReason(reason CommercialMigrationReason) bool { + switch reason { + case CommercialMigrationReasonExchangeUnavailable, CommercialMigrationReasonExchangeConnectivity: + return true + default: + return false + } +} + // ClassifyLegacyExchangeError converts startup/manual exchange errors into a // retryable or terminal v6 migration contract. func ClassifyLegacyExchangeError(err error) *CommercialMigrationStatus { @@ -101,15 +153,22 @@ func ClassifyLegacyExchangeError(err error) *CommercialMigrationStatus { var serverErr *LicenseServerError if errors.As(err, &serverErr) { + serverCode := strings.ToUpper(strings.TrimSpace(serverErr.Code)) switch serverErr.StatusCode { case 400: status.State = CommercialMigrationStateFailed status.Reason = CommercialMigrationReasonExchangeMalformed status.RecommendedAction = CommercialMigrationActionEnterSupportedV5 case 401: - status.State = CommercialMigrationStateFailed - status.Reason = CommercialMigrationReasonExchangeInvalid - status.RecommendedAction = CommercialMigrationActionEnterSupportedV5 + if serverCode == "RENEWED_KEY_AVAILABLE" { + status.State = CommercialMigrationStateFailed + status.Reason = CommercialMigrationReasonExchangeStaleKey + status.RecommendedAction = CommercialMigrationActionRetrieveCurrentKey + } else { + status.State = CommercialMigrationStateFailed + status.Reason = CommercialMigrationReasonExchangeInvalid + status.RecommendedAction = CommercialMigrationActionEnterSupportedV5 + } case 403: status.State = CommercialMigrationStateFailed status.Reason = CommercialMigrationReasonExchangeRevoked diff --git a/pkg/licensing/commercial_migration_test.go b/pkg/licensing/commercial_migration_test.go index 9c45776a7..a884170f6 100644 --- a/pkg/licensing/commercial_migration_test.go +++ b/pkg/licensing/commercial_migration_test.go @@ -3,6 +3,7 @@ package licensing import ( "fmt" "testing" + "time" ) func TestClassifyLegacyExchangeError(t *testing.T) { @@ -31,6 +32,13 @@ func TestClassifyLegacyExchangeError(t *testing.T) { wantState: CommercialMigrationStateFailed, wantReason: CommercialMigrationReasonExchangeInvalid, }, + { + name: "renewed key is terminal with retrieve guidance", + err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 401, Code: "RENEWED_KEY_AVAILABLE"}), + wantState: CommercialMigrationStateFailed, + wantReason: CommercialMigrationReasonExchangeStaleKey, + wantAction: CommercialMigrationActionRetrieveCurrentKey, + }, { name: "unsupported key format is terminal", err: fmt.Errorf("license key is not a supported v6 activation key or migratable v5 license"), @@ -70,3 +78,54 @@ func TestClassifyLegacyExchangeError(t *testing.T) { }) } } + +func TestApplyCommercialMigrationFailureTimingEscalatesSustainedTransportFailure(t *testing.T) { + start := time.Unix(1_700_000_000, 0) + initial := ApplyCommercialMigrationFailureTiming(&CommercialMigrationStatus{ + Source: CommercialMigrationSourceV5License, + State: CommercialMigrationStatePending, + Reason: CommercialMigrationReasonExchangeUnavailable, + RecommendedAction: CommercialMigrationActionRetryActivation, + }, nil, start) + if initial == nil { + t.Fatal("expected initial migration status") + } + if initial.FirstFailedAt != start.Unix() { + t.Fatalf("first_failed_at=%d want %d", initial.FirstFailedAt, start.Unix()) + } + if initial.Reason != CommercialMigrationReasonExchangeUnavailable { + t.Fatalf("initial reason=%q want %q", initial.Reason, CommercialMigrationReasonExchangeUnavailable) + } + + escalated := ApplyCommercialMigrationFailureTiming(&CommercialMigrationStatus{ + Source: CommercialMigrationSourceV5License, + State: CommercialMigrationStatePending, + Reason: CommercialMigrationReasonExchangeUnavailable, + RecommendedAction: CommercialMigrationActionRetryActivation, + }, initial, start.Add(time.Duration(CommercialMigrationSustainedExchangeUnavailableSeconds+1)*time.Second)) + if escalated == nil { + t.Fatal("expected escalated migration status") + } + if escalated.FirstFailedAt != start.Unix() { + t.Fatalf("escalated first_failed_at=%d want %d", escalated.FirstFailedAt, start.Unix()) + } + if escalated.Reason != CommercialMigrationReasonExchangeConnectivity { + t.Fatalf("escalated reason=%q want %q", escalated.Reason, CommercialMigrationReasonExchangeConnectivity) + } + if escalated.RecommendedAction != CommercialMigrationActionAllowLicenseEgress { + t.Fatalf("escalated action=%q want %q", escalated.RecommendedAction, CommercialMigrationActionAllowLicenseEgress) + } + + terminal := ApplyCommercialMigrationFailureTiming(&CommercialMigrationStatus{ + Source: CommercialMigrationSourceV5License, + State: CommercialMigrationStateFailed, + Reason: CommercialMigrationReasonExchangeInvalid, + RecommendedAction: CommercialMigrationActionEnterSupportedV5, + }, escalated, start.Add(48*time.Hour)) + if terminal == nil { + t.Fatal("expected terminal migration status") + } + if terminal.FirstFailedAt != 0 { + t.Fatalf("terminal first_failed_at=%d want 0", terminal.FirstFailedAt) + } +} diff --git a/pkg/licensing/license_server_client.go b/pkg/licensing/license_server_client.go index 2e9a04124..8ccf7a4f6 100644 --- a/pkg/licensing/license_server_client.go +++ b/pkg/licensing/license_server_client.go @@ -335,22 +335,40 @@ 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"` - LegacyCode string `json:"error"` - Message string `json:"message"` - Retryable bool `json:"retryable"` + Code string `json:"code"` + Error json.RawMessage `json:"error"` + Message string `json:"message"` + Retryable bool `json:"retryable"` } if json.Unmarshal(body, &parsed) == nil { code := strings.TrimSpace(parsed.Code) - if code == "" { - code = strings.TrimSpace(parsed.LegacyCode) + message := strings.TrimSpace(parsed.Message) + retryable := parsed.Retryable + if code == "" && len(parsed.Error) > 0 { + var legacyCode string + if json.Unmarshal(parsed.Error, &legacyCode) == nil { + code = strings.TrimSpace(legacyCode) + } else { + var nested struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable"` + } + if json.Unmarshal(parsed.Error, &nested) == nil { + code = strings.TrimSpace(nested.Code) + if strings.TrimSpace(nested.Message) != "" { + message = strings.TrimSpace(nested.Message) + } + retryable = nested.Retryable + } + } } if code != "" { apiErr.Code = code - if strings.TrimSpace(parsed.Message) != "" { - apiErr.Message = parsed.Message + if message != "" { + apiErr.Message = message } - apiErr.Retryable = parsed.Retryable + apiErr.Retryable = retryable } } } diff --git a/pkg/licensing/license_server_client_test.go b/pkg/licensing/license_server_client_test.go index f3986d5f9..b9119c538 100644 --- a/pkg/licensing/license_server_client_test.go +++ b/pkg/licensing/license_server_client_test.go @@ -160,6 +160,45 @@ func TestClientActivate(t *testing.T) { } }) + t.Run("server returns nested v6 error envelope", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": "RENEWED_KEY_AVAILABLE", + "message": "Legacy license key has been superseded by a renewal", + "retryable": false, + }, + }) + })) + defer server.Close() + + client := NewLicenseServerClient(server.URL) + _, err := client.ExchangeLegacyLicense(context.Background(), ExchangeLegacyLicenseRequest{ + LegacyLicenseKey: "header.payload.signature", + }) + if err == nil { + t.Fatal("expected error") + } + + apiErr, ok := err.(*LicenseServerError) + if !ok { + t.Fatalf("expected *LicenseServerError, got %T", err) + } + if apiErr.StatusCode != http.StatusUnauthorized { + t.Errorf("StatusCode = %d, want 401", apiErr.StatusCode) + } + if apiErr.Code != "RENEWED_KEY_AVAILABLE" { + t.Errorf("Code = %q, want RENEWED_KEY_AVAILABLE", apiErr.Code) + } + if apiErr.Message != "Legacy license key has been superseded by a renewal" { + t.Errorf("Message = %q, want nested message", apiErr.Message) + } + if apiErr.Retryable { + t.Error("expected Retryable=false for renewed key") + } + }) + 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) diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index bfa1a2d97..02d0160fc 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2914,10 +2914,10 @@ class SubsystemLookupTest(unittest.TestCase): self.assertEqual( api_match["matched_contract_references"], [ - { + { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 1179, + "line": 1189, "heading_line": 140, } ], diff --git a/tests/integration/tests/12-v5-commercial-migration.spec.ts b/tests/integration/tests/12-v5-commercial-migration.spec.ts index 32f239055..9629accf5 100644 --- a/tests/integration/tests/12-v5-commercial-migration.spec.ts +++ b/tests/integration/tests/12-v5-commercial-migration.spec.ts @@ -15,6 +15,7 @@ type EntitlementPayload = { state?: string; reason?: string; recommended_action?: string; + first_failed_at?: number; }; }; @@ -23,6 +24,13 @@ type ExpectedCopy = { bodyFragments: RegExp[]; }; +type MigrationFixture = { + state: string; + reason: string; + recommended_action: string; + first_failed_at?: number; +}; + const expectedState = process.env.PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_STATE || ''; const expectedReason = process.env.PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_REASON || ''; const expectedAction = process.env.PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_ACTION || ''; @@ -80,20 +88,28 @@ function expectFieldLocator(page: Page, label: string) { function expectedCopyFor(state: string, reason: string, action: string): ExpectedCopy { const actionFragment = action === 'retry_activation' - ? /retry activation from this instance/i + ? /retry from this instance/i : action === 'use_v6_activation_key' - ? /use the current v6 activation key for this purchase/i + ? /use the current v6 key for this purchase/i : action === 'enter_supported_v5_key' ? /retry with the original v5 pro\/lifetime key from this instance/i - : /review the activation state from this instance/i; + : action === 'free_installation_slot' + ? /support@pulserelay\.pro/i + : action === 'retrieve_current_key' + ? /pulserelay\.pro\/retrieve-license/i + : action === 'allow_license_egress' + ? /allow outbound HTTPS to license\.pulserelay\.pro/i + : /review the plan state from this instance/i; if (state === 'pending') { const reasonFragment = reason === 'exchange_rate_limited' ? /rate-limited right now/i : reason === 'exchange_conflict' - ? /another v6 activation handoff is still settling/i - : /automatic v6 exchange did not complete yet/i; + ? /another v6 license handoff is still settling/i + : reason === 'exchange_connectivity_required' + ? /paid v6 features require periodic outbound HTTPS/i + : /automatic v6 exchange did not complete yet/i; return { title: /v5 license migration pending/i, @@ -102,17 +118,21 @@ function expectedCopyFor(state: string, reason: string, action: string): Expecte } const reasonFragment = - reason === 'exchange_invalid' - ? /key was rejected during v6 migration/i - : reason === 'exchange_malformed' - ? /malformed and cannot be migrated/i - : reason === 'exchange_revoked' - ? /no longer eligible for automatic migration/i - : reason === 'exchange_non_migratable' - ? /not eligible for automatic v6 migration/i - : reason === 'exchange_unsupported' - ? /not a supported v5 pro\/lifetime migration input/i - : /could not be migrated automatically/i; + reason === 'exchange_installation_limit' + ? /maximum number of v6 installations/i + : reason === 'exchange_invalid' + ? /key was rejected during v6 migration/i + : reason === 'exchange_stale_key' + ? /superseded by a renewal/i + : reason === 'exchange_malformed' + ? /malformed and cannot be migrated/i + : reason === 'exchange_revoked' + ? /no longer eligible for automatic migration/i + : reason === 'exchange_non_migratable' + ? /not eligible for automatic v6 migration/i + : reason === 'exchange_unsupported' + ? /not a supported v5 pro\/lifetime migration input/i + : /could not be migrated automatically/i; return { title: /v5 license migration needs attention/i, @@ -120,6 +140,78 @@ function expectedCopyFor(state: string, reason: string, action: string): Expecte }; } +function freeEntitlementsWithMigration(migration: MigrationFixture): EntitlementPayload { + return { + valid: false, + tier: 'free', + plan_version: 'community', + subscription_state: 'expired', + trial_eligible: false, + trial_eligibility_reason: '', + limits: [], + commercial_migration: migration, + }; +} + +async function stubCommercialMigrationFixture(page: Page, migration: MigrationFixture) { + const entitlements = freeEntitlementsWithMigration(migration); + const commercialPosture = { + ...entitlements, + upgrade_reasons: [], + has_migration_gap: true, + }; + + await page.route('**/api/license/runtime-capabilities', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + capabilities: [], + limits: [], + hosted_mode: false, + max_history_days: 7, + runtime: { + build: 'community', + label: 'Pulse Community runtime', + }, + }), + }); + }); + + await page.route('**/api/license/entitlements', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(entitlements), + }); + }); + + await page.route('**/api/license/commercial-posture', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(commercialPosture), + }); + }); +} + +async function expectMigrationNotice(page: Page, migration: MigrationFixture) { + await page.goto('/settings/pulse-intelligence/billing/plan', { waitUntil: 'domcontentloaded' }); + await page.waitForURL(/\/settings/, { timeout: 10_000 }); + await expect(page.getByRole('heading', { name: /plans & billing/i }).first()).toBeVisible(); + + const expectedCopy = expectedCopyFor( + migration.state, + migration.reason, + migration.recommended_action, + ); + await expect(page.getByText(expectedCopy.title)).toBeVisible(); + for (const fragment of expectedCopy.bodyFragments) { + await expect(page.getByText(fragment)).toBeVisible(); + } + await expect(page.getByRole('button', { name: /start 14-day pro trial/i })).toHaveCount(0); +} + test.describe.serial('v5 commercial migration notice', () => { test.beforeEach(async ({}, testInfo) => { test.skip(!expectedState, 'Set PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_STATE to enable migration UI checks'); @@ -175,11 +267,10 @@ test.describe.serial('v5 commercial migration notice', () => { test('Pro settings renders the expected migration state', async ({ page }) => { await ensureAuthenticated(page); - await page.goto('/settings/system-pro', { waitUntil: 'domcontentloaded' }); + await page.goto('/settings/pulse-intelligence/billing/plan', { waitUntil: 'domcontentloaded' }); await page.waitForURL(/\/settings/, { timeout: 10_000 }); - await expect(page.getByRole('heading', { name: /pro license/i })).toBeVisible(); - await expect(page.getByRole('heading', { name: /current license/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /plans & billing/i }).first()).toBeVisible(); if (successMode) { await expect(page.getByText(/v5 license migration pending/i)).toHaveCount(0); @@ -211,3 +302,38 @@ test.describe.serial('v5 commercial migration notice', () => { await expect(page.getByRole('button', { name: /start 14-day pro trial/i })).toHaveCount(0); }); }); + +test.describe('v5 commercial migration notice fixtures', () => { + test('renders a stale renewed-key failure with retrieve-license guidance', async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only migration UI coverage'); + + const migration = { + state: 'failed', + reason: 'exchange_stale_key', + recommended_action: 'retrieve_current_key', + }; + await stubCommercialMigrationFixture(page, migration); + await ensureAuthenticated(page); + + await expectMigrationNotice(page, migration); + }); + + test('renders a sustained license-server egress requirement without offering a trial', async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only migration UI coverage'); + + const migration = { + state: 'pending', + reason: 'exchange_connectivity_required', + recommended_action: 'allow_license_egress', + first_failed_at: Math.floor(Date.now() / 1000) - 90_000, + }; + await stubCommercialMigrationFixture(page, migration); + await ensureAuthenticated(page); + + await expectMigrationNotice(page, migration); + }); +});