1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-21 18:53:29 +00:00

[PM-21705] Require userID for refreshAdditionalKeys() on key-service (#14810)

* Require userID for refreshAdditionalKeys()

* Add error handling to desktop Unlock settings

* Add more unit test coverage
This commit is contained in:
Thomas Avery
2025-06-06 13:38:25 -05:00
committed by GitHub
parent 3e4c37b8b3
commit 9d743a7ee0
8 changed files with 553 additions and 76 deletions

View File

@@ -25,6 +25,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { MessageSender } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -34,6 +35,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { DialogService, ToastService } from "@bitwarden/components";
import { BiometricStateService, BiometricsService, KeyService } from "@bitwarden/key-management";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service";
@@ -55,6 +58,10 @@ describe("AccountSecurityComponent", () => {
const biometricStateService = mock<BiometricStateService>();
const policyService = mock<PolicyService>();
const pinServiceAbstraction = mock<PinServiceAbstraction>();
const keyService = mock<KeyService>();
const validationService = mock<ValidationService>();
const dialogService = mock<DialogService>();
const platformUtilsService = mock<PlatformUtilsService>();
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -63,13 +70,13 @@ describe("AccountSecurityComponent", () => {
{ provide: AccountSecurityComponent, useValue: mock<AccountSecurityComponent>() },
{ provide: BiometricsService, useValue: mock<BiometricsService>() },
{ provide: BiometricStateService, useValue: biometricStateService },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: DialogService, useValue: dialogService },
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: MessageSender, useValue: mock<MessageSender>() },
{ provide: KeyService, useValue: mock<KeyService>() },
{ provide: KeyService, useValue: keyService },
{ provide: PinServiceAbstraction, useValue: pinServiceAbstraction },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: PolicyService, useValue: policyService },
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
{ provide: StateService, useValue: mock<StateService>() },
@@ -84,14 +91,17 @@ describe("AccountSecurityComponent", () => {
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
{ provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: ValidationService, useValue: validationService },
],
})
.overrideComponent(AccountSecurityComponent, {
remove: {
imports: [PopOutComponent],
providers: [DialogService],
},
add: {
imports: [MockPopOutComponent],
providers: [{ provide: DialogService, useValue: dialogService }],
},
})
.compileComponents();
@@ -106,10 +116,17 @@ describe("AccountSecurityComponent", () => {
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
of(VaultTimeoutAction.Lock),
);
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
of(VaultTimeoutAction.Lock),
);
biometricStateService.promptAutomatically$ = of(false);
pinServiceAbstraction.isPinSet.mockResolvedValue(false);
});
afterEach(() => {
jest.resetAllMocks();
});
it("pin enabled when RemoveUnlockWithPin policy is not set", async () => {
// @ts-strict-ignore
policyService.policiesByType$.mockReturnValue(of([null]));
@@ -211,4 +228,136 @@ describe("AccountSecurityComponent", () => {
const pinInputElement = fixture.debugElement.query(By.css("#pin"));
expect(pinInputElement).toBeNull();
});
describe("updateBiometric", () => {
let browserApiSpy: jest.SpyInstance;
beforeEach(() => {
policyService.policiesByType$.mockReturnValue(of([null]));
browserApiSpy = jest.spyOn(BrowserApi, "requestPermission");
browserApiSpy.mockResolvedValue(true);
});
describe("updating to false", () => {
it("calls biometricStateService methods with false when false", async () => {
await component.ngOnInit();
await component.updateBiometric(false);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(false);
expect(biometricStateService.setFingerprintValidated).toHaveBeenCalledWith(false);
});
});
describe("updating to true", () => {
let trySetupBiometricsSpy: jest.SpyInstance;
beforeEach(() => {
trySetupBiometricsSpy = jest.spyOn(component, "trySetupBiometrics");
});
it("displays permission error dialog when nativeMessaging permission is not granted", async () => {
browserApiSpy.mockResolvedValue(false);
await component.ngOnInit();
await component.updateBiometric(true);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "nativeMessaginPermissionErrorTitle" },
content: { key: "nativeMessaginPermissionErrorDesc" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "danger",
});
expect(component.form.controls.biometric.value).toBe(false);
expect(trySetupBiometricsSpy).not.toHaveBeenCalled();
});
it("displays a specific sidebar dialog when nativeMessaging permissions throws an error on firefox + sidebar", async () => {
browserApiSpy.mockRejectedValue(new Error("Permission denied"));
platformUtilsService.isFirefox.mockReturnValue(true);
jest.spyOn(BrowserPopupUtils, "inSidebar").mockReturnValue(true);
await component.ngOnInit();
await component.updateBiometric(true);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "nativeMessaginPermissionSidebarTitle" },
content: { key: "nativeMessaginPermissionSidebarDesc" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "info",
});
expect(component.form.controls.biometric.value).toBe(false);
expect(trySetupBiometricsSpy).not.toHaveBeenCalled();
});
test.each([
[false, false],
[false, true],
[true, false],
])(
"displays a generic dialog when nativeMessaging permissions throws an error and isFirefox is %s and onSidebar is %s",
async (isFirefox, inSidebar) => {
browserApiSpy.mockRejectedValue(new Error("Permission denied"));
platformUtilsService.isFirefox.mockReturnValue(isFirefox);
jest.spyOn(BrowserPopupUtils, "inSidebar").mockReturnValue(inSidebar);
await component.ngOnInit();
await component.updateBiometric(true);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({
title: { key: "nativeMessaginPermissionErrorTitle" },
content: { key: "nativeMessaginPermissionErrorDesc" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "danger",
});
expect(component.form.controls.biometric.value).toBe(false);
expect(trySetupBiometricsSpy).not.toHaveBeenCalled();
},
);
it("refreshes additional keys and attempts to setup biometrics when enabled with nativeMessaging permission", async () => {
const setupBiometricsResult = true;
trySetupBiometricsSpy.mockResolvedValue(setupBiometricsResult);
await component.ngOnInit();
await component.updateBiometric(true);
expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(
setupBiometricsResult,
);
expect(component.form.controls.biometric.value).toBe(setupBiometricsResult);
});
it("handles failed biometrics setup", async () => {
const setupBiometricsResult = false;
trySetupBiometricsSpy.mockResolvedValue(setupBiometricsResult);
await component.ngOnInit();
await component.updateBiometric(true);
expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(
setupBiometricsResult,
);
expect(biometricStateService.setFingerprintValidated).toHaveBeenCalledWith(
setupBiometricsResult,
);
expect(component.form.controls.biometric.value).toBe(setupBiometricsResult);
});
it("handles error during biometrics setup", async () => {
// Simulate an error during biometrics setup
keyService.refreshAdditionalKeys.mockRejectedValue(new Error("UserId is required"));
await component.ngOnInit();
await component.updateBiometric(true);
expect(validationService.showError).toHaveBeenCalledWith(new Error("UserId is required"));
expect(component.form.controls.biometric.value).toBe(false);
expect(trySetupBiometricsSpy).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -45,6 +45,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import {
DialogRef,
CardComponent,
@@ -153,6 +154,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private biometricsService: BiometricsService,
private vaultNudgesService: NudgesService,
private validationService: ValidationService,
) {}
async ngOnInit() {
@@ -520,13 +522,19 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
return;
}
await this.keyService.refreshAdditionalKeys();
try {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.keyService.refreshAdditionalKeys(userId);
const successful = await this.trySetupBiometrics();
this.form.controls.biometric.setValue(successful);
await this.biometricStateService.setBiometricUnlockEnabled(successful);
if (!successful) {
await this.biometricStateService.setFingerprintValidated(false);
const successful = await this.trySetupBiometrics();
this.form.controls.biometric.setValue(successful);
await this.biometricStateService.setBiometricUnlockEnabled(successful);
if (!successful) {
await this.biometricStateService.setFingerprintValidated(false);
}
} catch (error) {
this.form.controls.biometric.setValue(false);
this.validationService.showError(error);
}
} else {
await this.biometricStateService.setBiometricUnlockEnabled(false);