From 2f34c6b9c6528344341ea83694114364f1090600 Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Thu, 25 Sep 2025 18:09:53 -0400 Subject: [PATCH] [PM-26053] Create Autotype Desktop Default Setting Policy for use within the desktop autotype service (#16537) * add policy type enum * desktop autotype service which emits an observable * add desktop autotype default setting policy to the app constructor * update service module to include DesktopAutotypeDefaultSettingPolicy * flag the service * add tests * address comments, switch to null remove false, update tests --- apps/desktop/src/app/app.component.ts | 2 + .../src/app/services/services.module.ts | 11 +- .../desktop-autotype-policy.service.spec.ts | 166 ++++++++++++++++++ .../desktop-autotype-policy.service.ts | 60 +++++++ 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts create mode 100644 apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 1e7ef8e0000..1c2d3aa464d 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -77,6 +77,7 @@ import { KeyService, BiometricStateService } from "@bitwarden/key-management"; import { AddEditFolderDialogComponent, AddEditFolderDialogResult } from "@bitwarden/vault"; import { DeleteAccountComponent } from "../auth/delete-account.component"; +import { DesktopAutotypeDefaultSettingPolicy } from "../autofill/services/desktop-autotype-policy.service"; import { PremiumComponent } from "../billing/app/accounts/premium.component"; import { MenuAccount, MenuUpdateRequest } from "../main/menu/menu.updater"; @@ -177,6 +178,7 @@ export class AppComponent implements OnInit, OnDestroy { private readonly documentLangSetter: DocumentLangSetter, private restrictedItemTypesService: RestrictedItemTypesService, private readonly tokenService: TokenService, + private desktopAutotypeDefaultSettingPolicy: DesktopAutotypeDefaultSettingPolicy, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 094dea2e215..ea16f36402c 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -37,7 +37,10 @@ import { } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { + PolicyService as PolicyServiceAbstraction, + InternalPolicyService, +} from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService, AccountService as AccountServiceAbstraction, @@ -112,6 +115,7 @@ import { DesktopLoginComponentService } from "../../auth/login/desktop-login-com import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service"; +import { DesktopAutotypeDefaultSettingPolicy } from "../../autofill/services/desktop-autotype-policy.service"; import { DesktopAutotypeService } from "../../autofill/services/desktop-autotype.service"; import { DesktopFido2UserInterfaceService } from "../../autofill/services/desktop-fido2-user-interface.service"; import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service"; @@ -466,6 +470,11 @@ const safeProviders: SafeProvider[] = [ BillingAccountProfileStateService, ], }), + safeProvider({ + provide: DesktopAutotypeDefaultSettingPolicy, + useClass: DesktopAutotypeDefaultSettingPolicy, + deps: [AccountServiceAbstraction, AuthServiceAbstraction, InternalPolicyService, ConfigService], + }), ]; @NgModule({ diff --git a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts new file mode 100644 index 00000000000..7fb30333e28 --- /dev/null +++ b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts @@ -0,0 +1,166 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom, take, timeout, TimeoutError } from "rxjs"; + +import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { Account, UserId } from "@bitwarden/common/platform/models/domain/account"; + +import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service"; + +describe("DesktopAutotypeDefaultSettingPolicy", () => { + let service: DesktopAutotypeDefaultSettingPolicy; + let accountService: MockProxy; + let authService: MockProxy; + let policyService: MockProxy; + let configService: MockProxy; + + let mockAccountSubject: BehaviorSubject<{ id: UserId } | null>; + let mockFeatureFlagSubject: BehaviorSubject; + let mockAuthStatusSubject: BehaviorSubject; + let mockPolicyAppliesSubject: BehaviorSubject; + + const mockUserId = "user-123" as UserId; + + beforeEach(() => { + mockAccountSubject = new BehaviorSubject({ + id: mockUserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }); + mockFeatureFlagSubject = new BehaviorSubject(true); + mockAuthStatusSubject = new BehaviorSubject( + AuthenticationStatus.Unlocked, + ); + mockPolicyAppliesSubject = new BehaviorSubject(false); + + accountService = mock(); + authService = mock(); + policyService = mock(); + configService = mock(); + + accountService.activeAccount$ = mockAccountSubject.asObservable(); + configService.getFeatureFlag$ = jest + .fn() + .mockReturnValue(mockFeatureFlagSubject.asObservable()); + authService.authStatusFor$ = jest + .fn() + .mockImplementation((_: UserId) => mockAuthStatusSubject.asObservable()); + policyService.policyAppliesToUser$ = jest + .fn() + .mockReturnValue(mockPolicyAppliesSubject.asObservable()); + + TestBed.configureTestingModule({ + providers: [ + DesktopAutotypeDefaultSettingPolicy, + { provide: AccountService, useValue: accountService }, + { provide: AuthService, useValue: authService }, + { provide: InternalPolicyService, useValue: policyService }, + { provide: ConfigService, useValue: configService }, + ], + }); + + service = TestBed.inject(DesktopAutotypeDefaultSettingPolicy); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockAccountSubject.complete(); + mockFeatureFlagSubject.complete(); + mockAuthStatusSubject.complete(); + mockPolicyAppliesSubject.complete(); + }); + + describe("autotypeDefaultSetting$", () => { + it("should emit null when feature flag is disabled", async () => { + mockFeatureFlagSubject.next(false); + const result = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(result).toBeNull(); + }); + + it("should not emit when no active account", async () => { + mockAccountSubject.next(null); + await expect( + firstValueFrom(service.autotypeDefaultSetting$.pipe(timeout({ first: 30 }))), + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it("should emit null when user is not unlocked", async () => { + mockAuthStatusSubject.next(AuthenticationStatus.Locked); + const result = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(result).toBeNull(); + }); + + it("should emit null when no autotype policy exists", async () => { + mockPolicyAppliesSubject.next(false); + const policy = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(policy).toBeNull(); + }); + + it("should emit true when autotype policy is enabled", async () => { + mockPolicyAppliesSubject.next(true); + const policyStatus = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(policyStatus).toBe(true); + }); + + it("should emit false when autotype policy is disabled", async () => { + mockPolicyAppliesSubject.next(false); + const policyStatus = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(policyStatus).toBeNull(); + }); + + it("should emit null when autotype policy does not apply", async () => { + mockPolicyAppliesSubject.next(false); + const policy = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(policy).toBeNull(); + }); + + it("should react to authentication status changes", async () => { + // Expect one emission when unlocked + mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); + const first = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(first).toBeNull(); + + // Expect null emission when locked + mockAuthStatusSubject.next(AuthenticationStatus.Locked); + const lockedResult = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(lockedResult).toBeNull(); + }); + + it("should react to account changes", async () => { + const newUserId = "user-456" as UserId; + + // First value for original user + const firstValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(firstValue).toBeNull(); + + // Change account and expect a new emission + mockAccountSubject.next({ + id: newUserId, + }); + const secondValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(secondValue).toBeNull(); + + // Verify the auth lookup was switched to the new user + expect(authService.authStatusFor$).toHaveBeenCalledWith(newUserId); + }); + + it("should react to policy changes", async () => { + mockPolicyAppliesSubject.next(false); + const nullValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(nullValue).toBeNull(); + + mockPolicyAppliesSubject.next(true); + const trueValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(trueValue).toBe(true); + + mockPolicyAppliesSubject.next(false); + const nullValueAgain = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(nullValueAgain).toBeNull(); + }); + }); +}); diff --git a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts new file mode 100644 index 00000000000..76ffc090600 --- /dev/null +++ b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from "@angular/core"; +import { Observable, of } from "rxjs"; +import { distinctUntilChanged, filter, map, shareReplay, switchMap } from "rxjs/operators"; + +import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +@Injectable({ providedIn: "root" }) +export class DesktopAutotypeDefaultSettingPolicy { + constructor( + private readonly accountService: AccountService, + private readonly authService: AuthService, + private readonly policyService: InternalPolicyService, + private readonly configService: ConfigService, + ) {} + + /** + * Emits the autotype policy enabled status (true | false | null) when account is unlocked and WindowsDesktopAutotype is enabled. + * - true: autotype policy exists and is enabled + * - null: no autotype policy exists for the user's organization + */ + readonly autotypeDefaultSetting$: Observable = this.configService + .getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype) + .pipe( + switchMap((autotypeFeatureEnabled) => { + if (!autotypeFeatureEnabled) { + return of(null); + } + + return this.accountService.activeAccount$.pipe( + filter((account) => account != null), + getUserId, + distinctUntilChanged(), + switchMap((userId) => { + const isUnlocked$ = this.authService.authStatusFor$(userId).pipe( + map((status) => status === AuthenticationStatus.Unlocked), + distinctUntilChanged(), + ); + + const policy$ = this.policyService + .policyAppliesToUser$(PolicyType.AutotypeDefaultSetting, userId) + .pipe( + map((appliesToUser) => (appliesToUser ? true : null)), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + return isUnlocked$.pipe(switchMap((unlocked) => (unlocked ? policy$ : of(null)))); + }), + ); + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); +}