Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions apps/browser/src/popup/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<AccountService>;
let syncService: MockProxy<SyncService>;
let premiumCheckoutPendingService: MockProxy<PremiumCheckoutPendingService>;
let compactModeService: MockProxy<PopupCompactModeService>;
let popupSizeService: MockProxy<PopupSizeService>;
let authService: MockProxy<AuthService>;
let messageListener: MockProxy<MessageListener>;
let animationControlService: MockProxy<AnimationControlService>;
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringService>;
let router: MockProxy<Router>;
let ngZone: MockProxy<NgZone>;
let logService: MockProxy<LogService>;

const userId = "user-1" as UserId;

beforeEach(() => {
accountService = mock<AccountService>();
syncService = mock<SyncService>();
premiumCheckoutPendingService = mock<PremiumCheckoutPendingService>();
logService = mock<LogService>();
compactModeService = mock<PopupCompactModeService>();
popupSizeService = mock<PopupSizeService>();
authService = mock<AuthService>();
messageListener = mock<MessageListener>();
animationControlService = mock<AnimationControlService>();
authRequestAnsweringService = mock<AuthRequestAnsweringService>();
router = mock<Router>();
ngZone = mock<NgZone>();

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>();
documentLangSetter.start.mockReturnValue({ unsubscribe: jest.fn() } as any);
const deviceTrustToastService = mock<DeviceTrustToastService>();
deviceTrustToastService.setupListeners$ = EMPTY;

const sdkService = mock<SdkService>();
(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<I18nService>(),
router,
mock<TokenService>(),
mock<CipherService>(),
mock<ChangeDetectorRef>(),
ngZone,
mock<PlatformUtilsService>(),
mock<DialogService>(),
messageListener,
mock<ToastService>(),
accountService,
animationControlService,
mock<BiometricStateService>(),
mock<BiometricsService>(),
deviceTrustToastService,
mock<UserDecryptionOptionsServiceAbstraction>(),
mock<KeyService>(),
mock<DestroyRef>(),
documentLangSetter,
popupSizeService,
logService,
mock<AuthRequestServiceAbstraction>(),
mock<PendingAuthRequestsStateService>(),
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();
});
});
21 changes: 21 additions & 0 deletions apps/browser/src/popup/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();

Expand All @@ -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$
Expand Down Expand Up @@ -295,6 +302,20 @@ export class AppComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}

private async syncIfReturningFromCheckout(): Promise<void> {
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;
Expand Down
6 changes: 6 additions & 0 deletions apps/cli/src/service-container/service-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -344,6 +346,7 @@ export class ServiceContainer {
stateEventRunnerService: StateEventRunnerService;
biometricStateService: BiometricStateService;
billingAccountProfileStateService: BillingAccountProfileStateService;
premiumCheckoutPendingService: PremiumCheckoutPendingService;
providerApiService: ProviderApiServiceAbstraction;
userAutoUnlockKeyService: UserAutoUnlockKeyService;
kdfConfigService: KdfConfigService;
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading