diff --git a/apps/browser/src/popup/app.component.spec.ts b/apps/browser/src/popup/app.component.spec.ts new file mode 100644 index 000000000000..81d731ab18d0 --- /dev/null +++ b/apps/browser/src/popup/app.component.spec.ts @@ -0,0 +1,176 @@ +import { ChangeDetectorRef, DestroyRef, NgZone } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { EMPTY, of } from "rxjs"; + +import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; +import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; +import { + AuthRequestServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; +import { PremiumCheckoutPendingService } from "@bitwarden/common/billing/abstractions/account/premium-checkout-pending.service"; +import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { MessageListener } from "@bitwarden/common/platform/messaging"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { BiometricsService, BiometricStateService, KeyService } from "@bitwarden/key-management"; + +import { PopupCompactModeService } from "../platform/popup/layout/popup-compact-mode.service"; +import { PopupSizeService } from "../platform/popup/layout/popup-size.service"; + +import { AppComponent } from "./app.component"; + +describe("AppComponent (browser popup)", () => { + let component: AppComponent; + + let accountService: MockProxy; + let syncService: MockProxy; + let premiumCheckoutPendingService: MockProxy; + let compactModeService: MockProxy; + let popupSizeService: MockProxy; + let authService: MockProxy; + let messageListener: MockProxy; + let animationControlService: MockProxy; + let authRequestAnsweringService: MockProxy; + let router: MockProxy; + let ngZone: MockProxy; + let logService: MockProxy; + + const userId = "user-1" as UserId; + + beforeEach(() => { + accountService = mock(); + syncService = mock(); + premiumCheckoutPendingService = mock(); + logService = mock(); + compactModeService = mock(); + popupSizeService = mock(); + authService = mock(); + messageListener = mock(); + animationControlService = mock(); + authRequestAnsweringService = mock(); + router = mock(); + ngZone = mock(); + + accountService.activeAccount$ = of({ id: userId } as any); + authService.activeAccountStatus$ = EMPTY; + messageListener.allMessages$ = EMPTY; + animationControlService.enableRoutingAnimation$ = EMPTY; + (router as any).events = EMPTY; + popupSizeService.setHeight.mockResolvedValue(undefined); + ngZone.runOutsideAngular.mockImplementation((fn: () => unknown) => fn()); + + const documentLangSetter = mock(); + documentLangSetter.start.mockReturnValue({ unsubscribe: jest.fn() } as any); + const deviceTrustToastService = mock(); + deviceTrustToastService.setupListeners$ = EMPTY; + + const sdkService = mock(); + (sdkService as any).client$ = of(undefined); + + TestBed.configureTestingModule({ + providers: [ + { provide: PopupCompactModeService, useValue: compactModeService }, + { provide: SdkService, useValue: sdkService }, + ], + }); + + // The component uses `inject()` for `compactModeService`/`sdkService` field + // initializers, so it must be constructed inside an injection context. + component = TestBed.runInInjectionContext( + () => + new AppComponent( + authService, + mock(), + router, + mock(), + mock(), + mock(), + ngZone, + mock(), + mock(), + messageListener, + mock(), + accountService, + animationControlService, + mock(), + mock(), + deviceTrustToastService, + mock(), + mock(), + mock(), + documentLangSetter, + popupSizeService, + logService, + mock(), + mock(), + authRequestAnsweringService, + premiumCheckoutPendingService, + syncService, + ), + ); + + // Avoid touching the real chrome.runtime API during ngOnInit. + (global as any).chrome = { + runtime: { connect: jest.fn() }, + }; + (window as any).onmousedown = undefined; + }); + + afterEach(() => { + component.ngOnDestroy(); + jest.clearAllMocks(); + }); + + it("syncs once when a premium checkout was pending on popup open", async () => { + premiumCheckoutPendingService.consumeCheckoutPending.mockResolvedValue(true); + + await component.ngOnInit(); + + expect(premiumCheckoutPendingService.consumeCheckoutPending).toHaveBeenCalledWith(userId); + expect(syncService.fullSync).toHaveBeenCalledWith(true); + }); + + it("does not sync when no premium checkout was pending", async () => { + premiumCheckoutPendingService.consumeCheckoutPending.mockResolvedValue(false); + + await component.ngOnInit(); + + expect(syncService.fullSync).not.toHaveBeenCalled(); + }); + + it("fails closed: ngOnInit resolves and logs when consume throws", async () => { + const error = new Error("boom"); + premiumCheckoutPendingService.consumeCheckoutPending.mockRejectedValue(error); + + await expect(component.ngOnInit()).resolves.toBeUndefined(); + + expect(syncService.fullSync).not.toHaveBeenCalled(); + expect(logService.error).toHaveBeenCalledWith( + "Failed to sync after returning from premium checkout", + error, + ); + }); + + it("does not consume or sync when there is no active user", async () => { + accountService.activeAccount$ = of(null); + + await component.ngOnInit(); + + expect(premiumCheckoutPendingService.consumeCheckoutPending).not.toHaveBeenCalled(); + expect(syncService.fullSync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index fd457c6bcacf..afe93f460d5e 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -36,13 +36,16 @@ import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; +import { PremiumCheckoutPendingService } from "@bitwarden/common/billing/abstractions/account/premium-checkout-pending.service"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { @@ -114,6 +117,8 @@ export class AppComponent implements OnInit, OnDestroy { private authRequestService: AuthRequestServiceAbstraction, private pendingAuthRequestsState: PendingAuthRequestsStateService, private authRequestAnsweringService: AuthRequestAnsweringService, + private premiumCheckoutPendingService: PremiumCheckoutPendingService, + private syncService: SyncService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -131,6 +136,8 @@ export class AppComponent implements OnInit, OnDestroy { this.activeUserId = account?.id; }); + await this.syncIfReturningFromCheckout(); + this.authRequestAnsweringService.setupUnlockListenersForProcessingAuthRequests(this.destroy$); this.authService.activeAccountStatus$ @@ -295,6 +302,20 @@ export class AppComponent implements OnInit, OnDestroy { this.destroy$.complete(); } + private async syncIfReturningFromCheckout(): Promise { + try { + const userId = await firstValueFrom(getOptionalUserId(this.accountService.activeAccount$)); + if (userId == null) { + return; + } + if (await this.premiumCheckoutPendingService.consumeCheckoutPending(userId)) { + await this.syncService.fullSync(true); + } + } catch (e) { + this.logService.error("Failed to sync after returning from premium checkout", e); + } + } + getRouteElevation(outlet: RouterOutlet) { if (!this.routerAnimations) { return; diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 67dd982488eb..b043737d429e 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -75,7 +75,9 @@ import { DomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { PremiumCheckoutPendingService } from "@bitwarden/common/billing/abstractions/account/premium-checkout-pending.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; +import { DefaultPremiumCheckoutPendingService } from "@bitwarden/common/billing/services/account/default-premium-checkout-pending.service"; import { EventUploadService as EventUploadServiceAbstraction, EventCollectionService as EventCollectionServiceAbstraction, @@ -344,6 +346,7 @@ export class ServiceContainer { stateEventRunnerService: StateEventRunnerService; biometricStateService: BiometricStateService; billingAccountProfileStateService: BillingAccountProfileStateService; + premiumCheckoutPendingService: PremiumCheckoutPendingService; providerApiService: ProviderApiServiceAbstraction; userAutoUnlockKeyService: UserAutoUnlockKeyService; kdfConfigService: KdfConfigService; @@ -796,6 +799,9 @@ export class ServiceContainer { this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( this.stateProvider, ); + this.premiumCheckoutPendingService = new DefaultPremiumCheckoutPendingService( + this.stateProvider, + ); this.taskSchedulerService = new DefaultTaskSchedulerService(this.logService); diff --git a/apps/desktop/src/app/app.component.spec.ts b/apps/desktop/src/app/app.component.spec.ts new file mode 100644 index 000000000000..8f2fc0fbd52f --- /dev/null +++ b/apps/desktop/src/app/app.component.spec.ts @@ -0,0 +1,201 @@ +import { DestroyRef, NgZone } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { EMPTY, of } from "rxjs"; + +import { AccountDeletionService } from "@bitwarden/angular/auth/account-deletion/account-deletion.service"; +import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; +import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; +import { ModalService } from "@bitwarden/angular/services/modal.service"; +import { + AuthRequestServiceAbstraction, + LockService, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; +import { PremiumCheckoutPendingService } from "@bitwarden/common/billing/abstractions/account/premium-checkout-pending.service"; +import { EventUploadService } from "@bitwarden/common/dirt/event-logs"; +import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; +import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; +import { StateEventRunnerService } from "@bitwarden/common/platform/state"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { KeyService, BiometricStateService } from "@bitwarden/key-management"; + +import { AppComponent } from "./app.component"; + +describe("AppComponent (desktop)", () => { + let component: AppComponent; + + let broadcasterService: MockProxy; + let syncService: MockProxy; + let accountService: MockProxy; + let premiumCheckoutPendingService: MockProxy; + let ngZone: MockProxy; + let authRequestAnsweringService: MockProxy; + let logService: MockProxy; + + let broadcasterCallback: (message: any) => Promise; + + const userId = "user-1" as UserId; + + beforeEach(() => { + broadcasterService = mock(); + syncService = mock(); + accountService = mock(); + premiumCheckoutPendingService = mock(); + ngZone = mock(); + authRequestAnsweringService = mock(); + logService = mock(); + + accountService.activeAccount$ = of({ id: userId } as any); + (accountService as any).showHeader$ = EMPTY; + ngZone.run.mockImplementation((fn: () => unknown) => fn() as any); + ngZone.runOutsideAngular.mockImplementation((fn: () => unknown) => fn() as any); + + broadcasterService.subscribe.mockImplementation((_id: string, cb: (message: any) => void) => { + broadcasterCallback = cb as (message: any) => Promise; + }); + + const deviceTrustToastService = mock(); + deviceTrustToastService.setupListeners$ = EMPTY; + const documentLangSetter = mock(); + documentLangSetter.start.mockReturnValue({ unsubscribe: jest.fn() } as any); + + // The constructor calls `takeUntilDestroyed()`, which requires an injection context. + component = TestBed.runInInjectionContext( + () => + new AppComponent( + broadcasterService, + mock(), + syncService, + mock(), + mock(), + mock(), + mock(), + mock(), + ngZone, + mock(), + mock(), + logService, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + accountService, + deviceTrustToastService, + mock(), + mock(), + documentLangSetter, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + authRequestAnsweringService, + mock(), + mock(), + premiumCheckoutPendingService, + ), + ); + + component.ngOnInit(); + }); + + const dispatchMessage = async (message: any) => { + await broadcasterCallback(message); + }; + + it("syncs once on window focus when a premium checkout was pending", async () => { + premiumCheckoutPendingService.consumeCheckoutPending.mockResolvedValue(true); + + await dispatchMessage({ command: "windowIsFocused", windowIsFocused: true }); + + expect(premiumCheckoutPendingService.consumeCheckoutPending).toHaveBeenCalledWith(userId); + expect(syncService.fullSync).toHaveBeenCalledWith(true); + }); + + it("does not sync on window focus when nothing was pending", async () => { + premiumCheckoutPendingService.consumeCheckoutPending.mockResolvedValue(false); + + await dispatchMessage({ command: "windowIsFocused", windowIsFocused: true }); + + expect(syncService.fullSync).not.toHaveBeenCalled(); + }); + + it("does not consume or sync when the window lost focus (windowIsFocused: false)", async () => { + premiumCheckoutPendingService.consumeCheckoutPending.mockResolvedValue(true); + + await dispatchMessage({ command: "windowIsFocused", windowIsFocused: false }); + + expect(premiumCheckoutPendingService.consumeCheckoutPending).not.toHaveBeenCalled(); + expect(syncService.fullSync).not.toHaveBeenCalled(); + }); + + it("fails closed: window focus handling resolves and logs when consume throws", async () => { + const error = new Error("boom"); + premiumCheckoutPendingService.consumeCheckoutPending.mockRejectedValue(error); + + await expect( + dispatchMessage({ command: "windowIsFocused", windowIsFocused: true }), + ).resolves.toBeUndefined(); + + expect(syncService.fullSync).not.toHaveBeenCalled(); + expect(logService.error).toHaveBeenCalledWith( + "Failed to sync after returning from premium checkout", + error, + ); + }); + + it("does not consume or sync on window focus when there is no active user", async () => { + accountService.activeAccount$ = of(null); + + await dispatchMessage({ command: "windowIsFocused", windowIsFocused: true }); + + expect(premiumCheckoutPendingService.consumeCheckoutPending).not.toHaveBeenCalled(); + expect(syncService.fullSync).not.toHaveBeenCalled(); + }); + + it("syncs only once across repeated window focus events", async () => { + premiumCheckoutPendingService.consumeCheckoutPending.mockResolvedValueOnce(true); + premiumCheckoutPendingService.consumeCheckoutPending.mockResolvedValue(false); + + await dispatchMessage({ command: "windowIsFocused", windowIsFocused: true }); + await dispatchMessage({ command: "windowIsFocused", windowIsFocused: true }); + + expect(syncService.fullSync).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index f90d5208c0d9..43162aebef9d 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -35,8 +35,9 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service"; import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; +import { PremiumCheckoutPendingService } from "@bitwarden/common/billing/abstractions/account/premium-checkout-pending.service"; import { EventUploadService } from "@bitwarden/common/dirt/event-logs"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; @@ -182,6 +183,7 @@ export class AppComponent implements OnInit, OnDestroy { private authRequestAnsweringService: AuthRequestAnsweringService, private ssoLoginService: SsoLoginServiceAbstraction, private accountDeletionService: AccountDeletionService, + private premiumCheckoutPendingService: PremiumCheckoutPendingService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -432,6 +434,25 @@ export class AppComponent implements OnInit, OnDestroy { } this.messagingService.send("scheduleNextSync"); break; + case "windowIsFocused": { + if (message.windowIsFocused !== true) { + break; + } + try { + const userId = await firstValueFrom( + getOptionalUserId(this.accountService.activeAccount$), + ); + if ( + userId != null && + (await this.premiumCheckoutPendingService.consumeCheckoutPending(userId)) + ) { + await this.syncService.fullSync(true); + } + } catch (e) { + this.logService.error("Failed to sync after returning from premium checkout", e); + } + break; + } case "importVault": await this.dialogService.open(ImportDesktopComponent); break; diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts index 4210b8d881d3..0533456934a1 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -4,6 +4,8 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { firstValueFrom, of, throwError } from "rxjs"; import { ClientType } from "@bitwarden/client-type"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { PremiumCheckoutPendingService } from "@bitwarden/common/billing/abstractions/account/premium-checkout-pending.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PremiumCheckoutSessionResponse } from "@bitwarden/common/billing/models/response/premium-checkout-session.response"; @@ -20,6 +22,7 @@ import { } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { DialogRef, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -37,6 +40,8 @@ describe("PremiumUpgradeDialogComponent", () => { let mockLogService: jest.Mocked; let mockConfigService: jest.Mocked; let mockBillingApiService: jest.Mocked; + let mockAccountService: jest.Mocked; + let mockPremiumCheckoutPendingService: jest.Mocked; const mockPremiumTier: PersonalSubscriptionPricingTier = { id: PersonalSubscriptionPricingTierIds.Premium, @@ -109,6 +114,14 @@ describe("PremiumUpgradeDialogComponent", () => { createPremiumCheckoutSession: jest.fn(), } as any; + mockAccountService = { + activeAccount$: of({ id: "user-1" as UserId }), + } as any; + + mockPremiumCheckoutPendingService = { + markCheckoutLaunched: jest.fn().mockResolvedValue(undefined), + } as any; + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( of([mockPremiumTier, mockFamiliesTier]), ); @@ -132,6 +145,11 @@ describe("PremiumUpgradeDialogComponent", () => { { provide: LogService, useValue: mockLogService }, { provide: ConfigService, useValue: mockConfigService }, { provide: BillingApiServiceAbstraction, useValue: mockBillingApiService }, + { provide: AccountService, useValue: mockAccountService }, + { + provide: PremiumCheckoutPendingService, + useValue: mockPremiumCheckoutPendingService, + }, ], }).compileComponents(); @@ -195,6 +213,14 @@ describe("PremiumUpgradeDialogComponent", () => { expect(mockDialogRef.close).toHaveBeenCalled(); }); + it("does NOT mark checkout pending on the web-vault fallback", async () => { + mockConfigService.getFeatureFlag.mockResolvedValue(false); + + await component["upgrade"](); + + expect(mockPremiumCheckoutPendingService.markCheckoutLaunched).not.toHaveBeenCalled(); + }); + it("should launch web vault URL on self-host even when flag is enabled", async () => { // Checkout flag on, QA bypass flag off: self-host must still fall back. mockConfigService.getFeatureFlag.mockImplementation((flag: FeatureFlag) => @@ -238,6 +264,36 @@ describe("PremiumUpgradeDialogComponent", () => { expect(mockDialogRef.close).toHaveBeenCalled(); }); + it("marks checkout pending after launching external checkout", async () => { + mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Browser); + + await component["upgrade"](); + + expect(mockPremiumCheckoutPendingService.markCheckoutLaunched).toHaveBeenCalledWith( + "user-1" as UserId, + ); + expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( + "https://checkout.stripe.com/c/pay/cs_123", + ); + }); + + it("still launches checkout and shows no error toast when marking pending fails", async () => { + mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Browser); + const error = new Error("state write failed"); + mockPremiumCheckoutPendingService.markCheckoutLaunched.mockRejectedValue(error); + + await component["upgrade"](); + + expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( + "https://checkout.stripe.com/c/pay/cs_123", + ); + expect(mockToastService.showToast).not.toHaveBeenCalled(); + expect(mockLogService.error).toHaveBeenCalledWith( + "Failed to mark premium checkout as pending; sync recovery on refocus may not fire", + error, + ); + }); + it("should call checkout API with desktop platform on desktop client", async () => { mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts index 9e10a6df2f5d..4c99e08d6419 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts @@ -1,6 +1,8 @@ import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; import { of } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { PremiumCheckoutPendingService } from "@bitwarden/common/billing/abstractions/account/premium-checkout-pending.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { @@ -140,6 +142,19 @@ export default { error: {}, }, }, + { + provide: AccountService, + useValue: { + activeAccount$: of({ id: "test-user-id" }), + }, + }, + { + provide: PremiumCheckoutPendingService, + useValue: { + markCheckoutLaunched: () => Promise.resolve(), + consumeCheckoutPending: () => Promise.resolve(false), + }, + }, ], }), ], diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts index 0390e2613fb2..34c4dceb0052 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts @@ -4,6 +4,8 @@ import { ChangeDetectionStrategy, Component, signal } from "@angular/core"; import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs"; import { ClientType } from "@bitwarden/client-type"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { PremiumCheckoutPendingService } from "@bitwarden/common/billing/abstractions/account/premium-checkout-pending.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PremiumCheckoutSessionPlatform } from "@bitwarden/common/billing/models/request/premium-checkout-session.request"; @@ -76,6 +78,8 @@ export class PremiumUpgradeDialogComponent { private readonly logService: LogService, private readonly configService: ConfigService, private readonly billingApiService: BillingApiServiceAbstraction, + private readonly accountService: AccountService, + private readonly premiumCheckoutPendingService: PremiumCheckoutPendingService, ) {} protected async upgrade(): Promise { @@ -95,6 +99,9 @@ export class PremiumUpgradeDialogComponent { FeatureFlag.DebugDisableSelfHostPremiumCheck, ); const platform = this.resolveCheckoutPlatform(); + const userId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); if ( checkoutFlagEnabled && @@ -105,6 +112,17 @@ export class PremiumUpgradeDialogComponent { platform, }); this.platformUtilsService.launchUri(checkoutSessionUrl); + + if (userId != null) { + try { + await this.premiumCheckoutPendingService.markCheckoutLaunched(userId); + } catch (error: unknown) { + this.logService.error( + "Failed to mark premium checkout as pending; sync recovery on refocus may not fire", + error, + ); + } + } } else { const vaultUrl = environment.getWebVaultUrl() + diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 5c6b15275359..8c2c28bfd19c 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -165,12 +165,14 @@ import { } from "@bitwarden/common/billing/abstractions"; import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { PremiumCheckoutPendingService } from "@bitwarden/common/billing/abstractions/account/premium-checkout-pending.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; +import { DefaultPremiumCheckoutPendingService } from "@bitwarden/common/billing/services/account/default-premium-checkout-pending.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service"; import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service"; @@ -1691,6 +1693,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultBillingAccountProfileStateService, deps: [StateProvider], }), + safeProvider({ + provide: PremiumCheckoutPendingService, + useClass: DefaultPremiumCheckoutPendingService, + deps: [StateProvider], + }), safeProvider({ provide: SubscriptionPricingServiceAbstraction, useClass: DefaultSubscriptionPricingService, diff --git a/libs/common/src/billing/abstractions/account/premium-checkout-pending.service.ts b/libs/common/src/billing/abstractions/account/premium-checkout-pending.service.ts new file mode 100644 index 000000000000..413a9436ef6e --- /dev/null +++ b/libs/common/src/billing/abstractions/account/premium-checkout-pending.service.ts @@ -0,0 +1,19 @@ +import { UserId } from "../../../types/guid"; + +/** + * Tracks whether the active user was sent to an external Stripe checkout flow + * (browser tab / desktop default browser window) so the client can recover the + * one-time `PremiumStatusChanged` push by syncing when it regains focus. + * + * State is memory-scoped: it clears on app restart and on logout. + */ +export abstract class PremiumCheckoutPendingService { + /** Records that the user was sent to external checkout. */ + abstract markCheckoutLaunched(userId: UserId): Promise; + + /** + * Reads and clears the pending flag atomically. + * @returns `true` if a checkout was pending (caller should sync), otherwise `false`. + */ + abstract consumeCheckoutPending(userId: UserId): Promise; +} diff --git a/libs/common/src/billing/abstractions/index.ts b/libs/common/src/billing/abstractions/index.ts index 3f72cd9d2c0b..4f0adc84256c 100644 --- a/libs/common/src/billing/abstractions/index.ts +++ b/libs/common/src/billing/abstractions/index.ts @@ -1,3 +1,4 @@ export * from "./account/billing-account-profile-state.service"; +export * from "./account/premium-checkout-pending.service"; export * from "./billing-api.service.abstraction"; export * from "./organization-billing.service"; diff --git a/libs/common/src/billing/services/account/default-premium-checkout-pending.service.spec.ts b/libs/common/src/billing/services/account/default-premium-checkout-pending.service.spec.ts new file mode 100644 index 000000000000..03c9c7c25340 --- /dev/null +++ b/libs/common/src/billing/services/account/default-premium-checkout-pending.service.spec.ts @@ -0,0 +1,48 @@ +import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; +import { UserId } from "../../../types/guid"; + +import { DefaultPremiumCheckoutPendingService } from "./default-premium-checkout-pending.service"; +import { PREMIUM_CHECKOUT_PENDING_KEY } from "./premium-checkout-pending.state"; + +describe("DefaultPremiumCheckoutPendingService", () => { + const userId = "user-1" as UserId; + let stateProvider: FakeStateProvider; + let sut: DefaultPremiumCheckoutPendingService; + + beforeEach(() => { + stateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); + sut = new DefaultPremiumCheckoutPendingService(stateProvider); + }); + + it("consumeCheckoutPending returns false by default", async () => { + expect(await sut.consumeCheckoutPending(userId)).toBe(false); + }); + + it("markCheckoutLaunched makes the next consume return true", async () => { + await sut.markCheckoutLaunched(userId); + + expect(await sut.consumeCheckoutPending(userId)).toBe(true); + }); + + it("consumeCheckoutPending clears the flag so a second consume returns false", async () => { + await sut.markCheckoutLaunched(userId); + + expect(await sut.consumeCheckoutPending(userId)).toBe(true); + expect(await sut.consumeCheckoutPending(userId)).toBe(false); + }); + + it("is scoped per user", async () => { + const otherUserId = "user-2" as UserId; + await sut.markCheckoutLaunched(userId); + + expect(await sut.consumeCheckoutPending(otherUserId)).toBe(false); + }); + + // The "no stale flag survives a restart" guarantee depends entirely on this being + // memory-scoped. Pin it with code so flipping BILLING_MEMORY to disk fails here + // rather than silently producing phantom syncs. + it("is backed by memory-scoped state cleared on logout", () => { + expect(PREMIUM_CHECKOUT_PENDING_KEY.stateDefinition.defaultStorageLocation).toBe("memory"); + expect(PREMIUM_CHECKOUT_PENDING_KEY.clearOn).toEqual(["logout"]); + }); +}); diff --git a/libs/common/src/billing/services/account/default-premium-checkout-pending.service.ts b/libs/common/src/billing/services/account/default-premium-checkout-pending.service.ts new file mode 100644 index 000000000000..f63709134ce4 --- /dev/null +++ b/libs/common/src/billing/services/account/default-premium-checkout-pending.service.ts @@ -0,0 +1,23 @@ +import { StateProvider } from "@bitwarden/state"; + +import { UserId } from "../../../types/guid"; +import { PremiumCheckoutPendingService } from "../../abstractions/account/premium-checkout-pending.service"; + +import { PREMIUM_CHECKOUT_PENDING_KEY } from "./premium-checkout-pending.state"; + +export class DefaultPremiumCheckoutPendingService implements PremiumCheckoutPendingService { + constructor(private readonly stateProvider: StateProvider) {} + + async markCheckoutLaunched(userId: UserId): Promise { + await this.stateProvider.getUser(userId, PREMIUM_CHECKOUT_PENDING_KEY).update(() => true); + } + + async consumeCheckoutPending(userId: UserId): Promise { + let wasPending = false; + await this.stateProvider.getUser(userId, PREMIUM_CHECKOUT_PENDING_KEY).update((current) => { + wasPending = current === true; + return false; + }); + return wasPending; + } +} diff --git a/libs/common/src/billing/services/account/premium-checkout-pending.state.ts b/libs/common/src/billing/services/account/premium-checkout-pending.state.ts new file mode 100644 index 000000000000..4f0626bd8daf --- /dev/null +++ b/libs/common/src/billing/services/account/premium-checkout-pending.state.ts @@ -0,0 +1,15 @@ +import { BILLING_MEMORY, UserKeyDefinition } from "@bitwarden/state"; + +/** + * Memory-scoped, account-scoped flag indicating an external Stripe checkout was + * launched and a return-sync is pending. Cleared on logout (and on app restart, + * since the storage location is memory). + */ +export const PREMIUM_CHECKOUT_PENDING_KEY = new UserKeyDefinition( + BILLING_MEMORY, + "premiumCheckoutPending", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +);