1
0
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:
Thomas Avery
2026-02-18 17:08:33 -06:00
committed by GitHub
parent 1efd74daaf
commit c9b821262c
2 changed files with 161 additions and 16 deletions

View File

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

View File

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