mirror of
https://github.com/bitwarden/browser
synced 2026-02-20 11:24:07 +00:00
[PM-30927] Fix lock component initialization bug (#18822)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
@@ -605,4 +605,150 @@ describe("LockComponent", () => {
|
||||
expect(component.activeUnlockOption).toBe(UnlockOption.Biometrics);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listenForUnlockOptionsChanges", () => {
|
||||
const mockActiveAccount: Account = {
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
} as Account;
|
||||
|
||||
const mockUnlockOptions: UnlockOptions = {
|
||||
masterPassword: { enabled: true },
|
||||
pin: { enabled: false },
|
||||
biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available },
|
||||
prf: { enabled: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(component as any).loading = false;
|
||||
component.activeAccount = mockActiveAccount;
|
||||
component.activeUnlockOption = null;
|
||||
component.unlockOptions = null;
|
||||
mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(mockUnlockOptions));
|
||||
});
|
||||
|
||||
it("skips polling when loading is true", fakeAsync(() => {
|
||||
(component as any).loading = true;
|
||||
|
||||
component["listenForUnlockOptionsChanges"]();
|
||||
tick(0);
|
||||
|
||||
expect(mockLockComponentService.getAvailableUnlockOptions$).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("skips polling when activeAccount is null", fakeAsync(() => {
|
||||
component.activeAccount = null;
|
||||
|
||||
component["listenForUnlockOptionsChanges"]();
|
||||
tick(0);
|
||||
|
||||
expect(mockLockComponentService.getAvailableUnlockOptions$).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("fetches unlock options when loading is false and activeAccount exists", fakeAsync(() => {
|
||||
component["listenForUnlockOptionsChanges"]();
|
||||
tick(0);
|
||||
|
||||
expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledWith(userId);
|
||||
expect(component.unlockOptions).toEqual(mockUnlockOptions);
|
||||
}));
|
||||
|
||||
it("calls getAvailableUnlockOptions$ at 1000ms intervals", fakeAsync(() => {
|
||||
component["listenForUnlockOptionsChanges"]();
|
||||
|
||||
// Initial timer fire at 0ms
|
||||
tick(0);
|
||||
expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledTimes(1);
|
||||
|
||||
// First poll at 1000ms
|
||||
tick(1000);
|
||||
expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Second poll at 2000ms
|
||||
tick(1000);
|
||||
expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledTimes(3);
|
||||
}));
|
||||
|
||||
it("calls setDefaultActiveUnlockOption when activeUnlockOption is null", fakeAsync(() => {
|
||||
component.activeUnlockOption = null;
|
||||
const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption");
|
||||
|
||||
component["listenForUnlockOptionsChanges"]();
|
||||
tick(0);
|
||||
|
||||
expect(setDefaultSpy).toHaveBeenCalledWith(mockUnlockOptions);
|
||||
}));
|
||||
|
||||
it("does NOT call setDefaultActiveUnlockOption when activeUnlockOption is already set", fakeAsync(() => {
|
||||
component.activeUnlockOption = UnlockOption.MasterPassword;
|
||||
component.unlockOptions = mockUnlockOptions;
|
||||
|
||||
const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption");
|
||||
|
||||
component["listenForUnlockOptionsChanges"]();
|
||||
tick(0);
|
||||
|
||||
expect(setDefaultSpy).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("calls setDefaultActiveUnlockOption when biometrics becomes enabled", fakeAsync(() => {
|
||||
component.activeUnlockOption = UnlockOption.MasterPassword;
|
||||
|
||||
// Start with biometrics disabled
|
||||
component.unlockOptions = {
|
||||
masterPassword: { enabled: true },
|
||||
pin: { enabled: false },
|
||||
biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available },
|
||||
prf: { enabled: false },
|
||||
};
|
||||
|
||||
// Mock response with biometrics enabled
|
||||
const newUnlockOptions: UnlockOptions = {
|
||||
masterPassword: { enabled: true },
|
||||
pin: { enabled: false },
|
||||
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available },
|
||||
prf: { enabled: false },
|
||||
};
|
||||
|
||||
mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(newUnlockOptions));
|
||||
|
||||
const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption");
|
||||
const handleBioSpy = jest.spyOn(component as any, "handleBiometricsUnlockEnabled");
|
||||
|
||||
component["listenForUnlockOptionsChanges"]();
|
||||
tick(0);
|
||||
|
||||
expect(setDefaultSpy).toHaveBeenCalledWith(newUnlockOptions);
|
||||
expect(handleBioSpy).toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("does NOT call setDefaultActiveUnlockOption when biometrics was already enabled", fakeAsync(() => {
|
||||
component.activeUnlockOption = UnlockOption.MasterPassword;
|
||||
|
||||
// Start with biometrics already enabled
|
||||
component.unlockOptions = {
|
||||
masterPassword: { enabled: true },
|
||||
pin: { enabled: false },
|
||||
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available },
|
||||
prf: { enabled: false },
|
||||
};
|
||||
|
||||
// Mock response with biometrics still enabled
|
||||
const newUnlockOptions: UnlockOptions = {
|
||||
masterPassword: { enabled: true },
|
||||
pin: { enabled: false },
|
||||
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available },
|
||||
prf: { enabled: false },
|
||||
};
|
||||
mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(newUnlockOptions));
|
||||
|
||||
const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption");
|
||||
|
||||
component["listenForUnlockOptionsChanges"]();
|
||||
tick(0);
|
||||
|
||||
expect(setDefaultSpy).not.toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,7 +202,8 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
timer(0, 1000)
|
||||
.pipe(
|
||||
mergeMap(async () => {
|
||||
if (this.activeAccount?.id != null) {
|
||||
// Only perform polling after the component has loaded. This prevents multiple sources setting the default active unlock option on initialization.
|
||||
if (this.loading === false && this.activeAccount?.id != null) {
|
||||
const prevBiometricsEnabled = this.unlockOptions?.biometrics.enabled;
|
||||
|
||||
this.unlockOptions = await firstValueFrom(
|
||||
@@ -210,7 +211,6 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
|
||||
if (this.activeUnlockOption == null) {
|
||||
this.loading = false;
|
||||
await this.setDefaultActiveUnlockOption(this.unlockOptions);
|
||||
} else if (!prevBiometricsEnabled && this.unlockOptions?.biometrics.enabled) {
|
||||
await this.setDefaultActiveUnlockOption(this.unlockOptions);
|
||||
@@ -275,19 +275,18 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
this.lockComponentService.getAvailableUnlockOptions$(activeAccount.id),
|
||||
);
|
||||
|
||||
const canUseBiometrics = [
|
||||
BiometricsStatus.Available,
|
||||
...BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES,
|
||||
].includes(await this.biometricService.getBiometricsStatusForUser(activeAccount.id));
|
||||
if (
|
||||
!this.unlockOptions?.masterPassword.enabled &&
|
||||
!this.unlockOptions?.pin.enabled &&
|
||||
!canUseBiometrics
|
||||
) {
|
||||
// User has no available unlock options, force logout. This happens for TDE users without a masterpassword, that don't have a persistent unlock method set.
|
||||
this.logService.warning("[LockComponent] User cannot unlock again. Logging out!");
|
||||
await this.logoutService.logout(activeAccount.id);
|
||||
return;
|
||||
// The canUseBiometrics query is an expensive operation. Only call if both PIN and master password unlock are unavailable.
|
||||
if (!this.unlockOptions?.masterPassword.enabled && !this.unlockOptions?.pin.enabled) {
|
||||
const canUseBiometrics = [
|
||||
BiometricsStatus.Available,
|
||||
...BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES,
|
||||
].includes(await this.biometricService.getBiometricsStatusForUser(activeAccount.id));
|
||||
if (!canUseBiometrics) {
|
||||
// User has no available unlock options, force logout. This happens for TDE users without a masterpassword, that don't have a persistent unlock method set.
|
||||
this.logService.warning("[LockComponent] User cannot unlock again. Logging out!");
|
||||
await this.logoutService.logout(activeAccount.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.setDefaultActiveUnlockOption(this.unlockOptions);
|
||||
|
||||
Reference in New Issue
Block a user