diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 47c4d14fc98..915f8a2d30e 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -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(); + })); + }); }); diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 9900aa6e827..5686e4b334a 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -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);