1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[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
This commit is contained in:
Daniel Riera
2025-09-25 18:09:53 -04:00
committed by GitHub
parent b56c9e6c9a
commit 2f34c6b9c6
4 changed files with 238 additions and 1 deletions

View File

@@ -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();

View File

@@ -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({

View File

@@ -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<AccountService>;
let authService: MockProxy<AuthService>;
let policyService: MockProxy<InternalPolicyService>;
let configService: MockProxy<ConfigService>;
let mockAccountSubject: BehaviorSubject<{ id: UserId } | null>;
let mockFeatureFlagSubject: BehaviorSubject<boolean>;
let mockAuthStatusSubject: BehaviorSubject<AuthenticationStatus>;
let mockPolicyAppliesSubject: BehaviorSubject<boolean>;
const mockUserId = "user-123" as UserId;
beforeEach(() => {
mockAccountSubject = new BehaviorSubject<Account | null>({
id: mockUserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
});
mockFeatureFlagSubject = new BehaviorSubject<boolean>(true);
mockAuthStatusSubject = new BehaviorSubject<AuthenticationStatus>(
AuthenticationStatus.Unlocked,
);
mockPolicyAppliesSubject = new BehaviorSubject<boolean>(false);
accountService = mock<AccountService>();
authService = mock<AuthService>();
policyService = mock<InternalPolicyService>();
configService = mock<ConfigService>();
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();
});
});
});

View File

@@ -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<boolean | null> = 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 }),
);
}