mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23:34 +00:00
lock component unit test coverage (#15801)
This commit is contained in:
@@ -0,0 +1,677 @@
|
|||||||
|
import { DebugElement } from "@angular/core";
|
||||||
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||||
|
import { By } from "@angular/platform-browser";
|
||||||
|
import { Router } from "@angular/router";
|
||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
import { firstValueFrom, interval, map, of, takeWhile, timeout } from "rxjs";
|
||||||
|
import { ZXCVBNResult } from "zxcvbn";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { LogoutService, PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||||
|
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||||
|
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||||
|
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||||
|
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
|
||||||
|
import {
|
||||||
|
MasterPasswordVerification,
|
||||||
|
MasterPasswordVerificationResponse,
|
||||||
|
} from "@bitwarden/common/auth/types/verification";
|
||||||
|
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
||||||
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||||
|
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||||
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||||
|
import { mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||||
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||||
|
import {
|
||||||
|
AnonLayoutWrapperDataService,
|
||||||
|
AsyncActionsModule,
|
||||||
|
ButtonModule,
|
||||||
|
DialogService,
|
||||||
|
FormFieldModule,
|
||||||
|
IconButtonModule,
|
||||||
|
ToastService,
|
||||||
|
} from "@bitwarden/components";
|
||||||
|
import {
|
||||||
|
BiometricsService,
|
||||||
|
BiometricsStatus,
|
||||||
|
BiometricStateService,
|
||||||
|
KeyService,
|
||||||
|
PBKDF2KdfConfig,
|
||||||
|
UserAsymmetricKeysRegenerationService,
|
||||||
|
} from "@bitwarden/key-management";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LockComponentService,
|
||||||
|
UnlockOption,
|
||||||
|
UnlockOptions,
|
||||||
|
} from "../services/lock-component.service";
|
||||||
|
|
||||||
|
import { LockComponent } from "./lock.component";
|
||||||
|
|
||||||
|
describe("LockComponent", () => {
|
||||||
|
let component: LockComponent;
|
||||||
|
let fixture: ComponentFixture<LockComponent>;
|
||||||
|
|
||||||
|
const userId = "test-user-id" as UserId;
|
||||||
|
|
||||||
|
// Mock services
|
||||||
|
const mockAccountService = mockAccountServiceWith(userId);
|
||||||
|
const mockPinService = mock<PinServiceAbstraction>();
|
||||||
|
const mockUserVerificationService = mock<UserVerificationService>();
|
||||||
|
const mockKeyService = mock<KeyService>();
|
||||||
|
const mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||||
|
const mockRouter = mock<Router>();
|
||||||
|
const mockDialogService = mock<DialogService>();
|
||||||
|
const mockMessagingService = mock<MessagingService>();
|
||||||
|
const mockBiometricStateService = mock<BiometricStateService>();
|
||||||
|
const mockI18nService = mock<I18nService>();
|
||||||
|
const mockMasterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||||
|
const mockLogService = mock<LogService>();
|
||||||
|
const mockDeviceTrustService = mock<DeviceTrustServiceAbstraction>();
|
||||||
|
const mockSyncService = mock<SyncService>();
|
||||||
|
const mockPolicyService = mock<InternalPolicyService>();
|
||||||
|
const mockPasswordStrengthService = mock<PasswordStrengthServiceAbstraction>();
|
||||||
|
const mockToastService = mock<ToastService>();
|
||||||
|
const mockUserAsymmetricKeysRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
|
||||||
|
const mockBiometricService = mock<BiometricsService>();
|
||||||
|
const mockLogoutService = mock<LogoutService>();
|
||||||
|
const mockLockComponentService = mock<LockComponentService>();
|
||||||
|
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
|
||||||
|
const mockBroadcasterService = mock<BroadcasterService>();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Setup default mock returns
|
||||||
|
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web);
|
||||||
|
mockKeyService.hasUserKey.mockResolvedValue(false);
|
||||||
|
mockI18nService.t.mockImplementation((key: string) => key);
|
||||||
|
|
||||||
|
// Mock observables that cause timeouts
|
||||||
|
mockBiometricStateService.promptAutomatically$ = of(false);
|
||||||
|
mockBiometricStateService.promptCancelled$ = of(false);
|
||||||
|
mockBiometricStateService.resetUserPromptCancelled.mockResolvedValue();
|
||||||
|
mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(null));
|
||||||
|
mockSyncService.fullSync.mockResolvedValue(true);
|
||||||
|
mockDeviceTrustService.trustDeviceIfRequired.mockResolvedValue();
|
||||||
|
mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded.mockResolvedValue();
|
||||||
|
mockAnonLayoutWrapperDataService.setAnonLayoutWrapperData.mockImplementation(() => {});
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
LockComponent,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
JslibModule,
|
||||||
|
ButtonModule,
|
||||||
|
FormFieldModule,
|
||||||
|
AsyncActionsModule,
|
||||||
|
IconButtonModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
FormBuilder,
|
||||||
|
{ provide: AccountService, useValue: mockAccountService },
|
||||||
|
{ provide: PinServiceAbstraction, useValue: mockPinService },
|
||||||
|
{ provide: UserVerificationService, useValue: mockUserVerificationService },
|
||||||
|
{ provide: KeyService, useValue: mockKeyService },
|
||||||
|
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||||
|
{ provide: Router, useValue: mockRouter },
|
||||||
|
{ provide: DialogService, useValue: mockDialogService },
|
||||||
|
{ provide: MessagingService, useValue: mockMessagingService },
|
||||||
|
{ provide: BiometricStateService, useValue: mockBiometricStateService },
|
||||||
|
{ provide: I18nService, useValue: mockI18nService },
|
||||||
|
{ provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService },
|
||||||
|
{ provide: LogService, useValue: mockLogService },
|
||||||
|
{ provide: DeviceTrustServiceAbstraction, useValue: mockDeviceTrustService },
|
||||||
|
{ provide: SyncService, useValue: mockSyncService },
|
||||||
|
{ provide: InternalPolicyService, useValue: mockPolicyService },
|
||||||
|
{ provide: PasswordStrengthServiceAbstraction, useValue: mockPasswordStrengthService },
|
||||||
|
{ provide: ToastService, useValue: mockToastService },
|
||||||
|
{
|
||||||
|
provide: UserAsymmetricKeysRegenerationService,
|
||||||
|
useValue: mockUserAsymmetricKeysRegenerationService,
|
||||||
|
},
|
||||||
|
{ provide: BiometricsService, useValue: mockBiometricService },
|
||||||
|
{ provide: LogoutService, useValue: mockLogoutService },
|
||||||
|
{ provide: LockComponentService, useValue: mockLockComponentService },
|
||||||
|
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
|
||||||
|
{ provide: BroadcasterService, useValue: mockBroadcasterService },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideProvider(DialogService, { useValue: mockDialogService })
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(LockComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when master password unlock is active", () => {
|
||||||
|
let form: DebugElement;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const unlockOptions: UnlockOptions = {
|
||||||
|
masterPassword: { enabled: true },
|
||||||
|
pin: { enabled: false },
|
||||||
|
biometrics: {
|
||||||
|
enabled: false,
|
||||||
|
biometricsStatus: BiometricsStatus.NotEnabledLocally,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
component.activeUnlockOption = UnlockOption.MasterPassword;
|
||||||
|
mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(unlockOptions));
|
||||||
|
await mockAccountService.switchAccount(userId);
|
||||||
|
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web);
|
||||||
|
|
||||||
|
mockI18nService.t.mockImplementation((key: string) => {
|
||||||
|
switch (key) {
|
||||||
|
case "unlock":
|
||||||
|
return "Unlock";
|
||||||
|
case "logOut":
|
||||||
|
return "Log Out";
|
||||||
|
case "logOutConfirmation":
|
||||||
|
return "Confirm Log Out";
|
||||||
|
case "masterPass":
|
||||||
|
return "Master Password";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger ngOnInit
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
// Wait for html loading to complete
|
||||||
|
await firstValueFrom(
|
||||||
|
interval(10).pipe(
|
||||||
|
map(() => component["loading"]),
|
||||||
|
takeWhile((loading) => loading, true),
|
||||||
|
timeout(5000),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for html to render
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
form = fixture.debugElement.query(By.css("form"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("form rendering", () => {
|
||||||
|
it("should render form with label", () => {
|
||||||
|
expect(form).toBeTruthy();
|
||||||
|
expect(form.nativeElement).toBeInstanceOf(HTMLFormElement);
|
||||||
|
|
||||||
|
const bitLabel = form.query(By.css("bit-label"));
|
||||||
|
expect(bitLabel).toBeTruthy();
|
||||||
|
expect(bitLabel.nativeElement).toBeInstanceOf(HTMLElement);
|
||||||
|
expect((bitLabel.nativeElement as HTMLElement).textContent?.trim()).toBe("Master Password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render master password input field", () => {
|
||||||
|
const input = form.query(By.css('input[formControlName="masterPassword"]'));
|
||||||
|
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
expect(input.nativeElement).toBeInstanceOf(HTMLInputElement);
|
||||||
|
const inputElement = input.nativeElement as HTMLInputElement;
|
||||||
|
expect(inputElement.type).toEqual("password");
|
||||||
|
expect(inputElement.name).toEqual("masterPassword");
|
||||||
|
expect(inputElement.required).toEqual(true);
|
||||||
|
expect(inputElement.attributes).toHaveProperty("bitInput");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render password toggle button", () => {
|
||||||
|
const toggleButton = form.query(By.css("button[bitPasswordInputToggle]"));
|
||||||
|
|
||||||
|
expect(toggleButton).toBeTruthy();
|
||||||
|
expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
|
||||||
|
const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement;
|
||||||
|
expect(toggleButtonElement.type).toEqual("button");
|
||||||
|
expect(toggleButtonElement.attributes).toHaveProperty("bitIconButton");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render unlock submit button", () => {
|
||||||
|
const submitButton = form.query(By.css('button[type="submit"]'));
|
||||||
|
|
||||||
|
expect(submitButton).toBeTruthy();
|
||||||
|
expect(submitButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
|
||||||
|
const submitButtonElement = submitButton.nativeElement as HTMLButtonElement;
|
||||||
|
expect(submitButtonElement.type).toEqual("submit");
|
||||||
|
expect(submitButtonElement.attributes).toHaveProperty("bitButton");
|
||||||
|
expect(submitButtonElement.attributes).toHaveProperty("bitFormButton");
|
||||||
|
expect(submitButtonElement.textContent?.trim()).toEqual("Unlock");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render logout button", () => {
|
||||||
|
const logoutButton = form.query(
|
||||||
|
By.css('button[type="button"]:not([bitPasswordInputToggle])'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logoutButton).toBeTruthy();
|
||||||
|
expect(logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
|
||||||
|
const logoutButtonElement = logoutButton.nativeElement as HTMLButtonElement;
|
||||||
|
expect(logoutButtonElement.type).toEqual("button");
|
||||||
|
expect(logoutButtonElement.textContent?.trim()).toEqual("Log Out");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unlock", () => {
|
||||||
|
it("should unlock with master password when unlock button is clicked", async () => {
|
||||||
|
const unlockViaMasterPasswordFunction = jest
|
||||||
|
.spyOn(component, "unlockViaMasterPassword")
|
||||||
|
.mockImplementation();
|
||||||
|
const submitButton = form.query(By.css('button[type="submit"]'));
|
||||||
|
expect(submitButton).toBeTruthy();
|
||||||
|
expect(submitButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
|
||||||
|
const submitButtonElement = submitButton.nativeElement as HTMLButtonElement;
|
||||||
|
submitButtonElement.click();
|
||||||
|
|
||||||
|
expect(unlockViaMasterPasswordFunction).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("logout", () => {
|
||||||
|
it("should logout when logout button is clicked", async () => {
|
||||||
|
const logOut = jest.spyOn(component, "logOut").mockImplementation();
|
||||||
|
const logoutButton = form.query(
|
||||||
|
By.css('button[type="button"]:not([bitPasswordInputToggle])'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logoutButton).toBeTruthy();
|
||||||
|
expect(logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
|
||||||
|
const logoutButtonElement = logoutButton.nativeElement as HTMLButtonElement;
|
||||||
|
|
||||||
|
logoutButtonElement.click();
|
||||||
|
|
||||||
|
expect(logOut).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password input", () => {
|
||||||
|
it("should bind form input to masterPassword form control", async () => {
|
||||||
|
const input = form.query(By.css('input[formControlName="masterPassword"]'));
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
expect(input.nativeElement).toBeInstanceOf(HTMLInputElement);
|
||||||
|
expect(component.formGroup).toBeTruthy();
|
||||||
|
const masterPasswordControl = component.formGroup!.get("masterPassword");
|
||||||
|
expect(masterPasswordControl).toBeTruthy();
|
||||||
|
|
||||||
|
masterPasswordControl!.setValue("test-password");
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const inputElement = input.nativeElement as HTMLInputElement;
|
||||||
|
expect(inputElement.value).toEqual("test-password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate required master password field", async () => {
|
||||||
|
const formGroup = component.formGroup;
|
||||||
|
|
||||||
|
// Initially form should be invalid (empty required field)
|
||||||
|
expect(formGroup?.invalid).toEqual(true);
|
||||||
|
expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(true);
|
||||||
|
|
||||||
|
// Set a value
|
||||||
|
formGroup?.get("masterPassword")?.setValue("test-password");
|
||||||
|
|
||||||
|
expect(formGroup?.invalid).toEqual(false);
|
||||||
|
expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle password visibility when toggle button is clicked", async () => {
|
||||||
|
const toggleButton = form.query(By.css("button[bitPasswordInputToggle]"));
|
||||||
|
expect(toggleButton).toBeTruthy();
|
||||||
|
expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
|
||||||
|
const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement;
|
||||||
|
const input = form.query(By.css('input[formControlName="masterPassword"]'));
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
expect(input.nativeElement).toBeInstanceOf(HTMLInputElement);
|
||||||
|
const inputElement = input.nativeElement as HTMLInputElement;
|
||||||
|
|
||||||
|
// Initially password should be hidden
|
||||||
|
expect(component.showPassword).toEqual(false);
|
||||||
|
expect(inputElement.type).toEqual("password");
|
||||||
|
|
||||||
|
// Click toggle button
|
||||||
|
toggleButtonElement.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.showPassword).toEqual(true);
|
||||||
|
expect(inputElement.type).toEqual("text");
|
||||||
|
|
||||||
|
// Click toggle button again
|
||||||
|
toggleButtonElement.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.showPassword).toEqual(false);
|
||||||
|
expect(inputElement.type).toEqual("password");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unlockViaMasterPassword", () => {
|
||||||
|
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey;
|
||||||
|
const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = {
|
||||||
|
masterKey: mockMasterKey,
|
||||||
|
kdfConfig: new PBKDF2KdfConfig(600_001),
|
||||||
|
email: "test-email@example.com",
|
||||||
|
policyOptions: null,
|
||||||
|
};
|
||||||
|
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||||
|
const masterPassword = "test-password";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockI18nService.t.mockImplementation((key: string) => {
|
||||||
|
switch (key) {
|
||||||
|
case "errorOccurred":
|
||||||
|
return "Error Occurred";
|
||||||
|
case "masterPasswordRequired":
|
||||||
|
return "Master Password is required";
|
||||||
|
case "invalidMasterPassword":
|
||||||
|
return "Invalid Master Password";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
component.buildMasterPasswordForm();
|
||||||
|
component.formGroup!.controls.masterPassword.setValue(masterPassword);
|
||||||
|
component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$);
|
||||||
|
mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue(
|
||||||
|
masterPasswordVerificationResponse,
|
||||||
|
);
|
||||||
|
mockMasterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not unlock and show password invalid toast when master password is empty", async () => {
|
||||||
|
component.formGroup!.controls.masterPassword.setValue("");
|
||||||
|
|
||||||
|
await component.unlockViaMasterPassword();
|
||||||
|
|
||||||
|
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
title: "Error Occurred",
|
||||||
|
message: "Master Password is required",
|
||||||
|
});
|
||||||
|
expect(mockKeyService.setUserKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not unlock when no active account", async () => {
|
||||||
|
component.activeAccount = null;
|
||||||
|
|
||||||
|
await component.unlockViaMasterPassword();
|
||||||
|
|
||||||
|
expect(mockToastService.showToast).not.toHaveBeenCalled();
|
||||||
|
expect(mockKeyService.setUserKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not unlock when no form group", async () => {
|
||||||
|
component.formGroup = null;
|
||||||
|
|
||||||
|
await component.unlockViaMasterPassword();
|
||||||
|
|
||||||
|
expect(mockToastService.showToast).not.toHaveBeenCalled();
|
||||||
|
expect(mockKeyService.setUserKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not unlock when input password verification failed due to invalid password", async () => {
|
||||||
|
mockUserVerificationService.verifyUserByMasterPassword.mockRejectedValueOnce(
|
||||||
|
new Error("invalid password"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await component.unlockViaMasterPassword();
|
||||||
|
|
||||||
|
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
title: "Error Occurred",
|
||||||
|
message: "Invalid Master Password",
|
||||||
|
});
|
||||||
|
expect(mockUserVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: VerificationType.MasterPassword,
|
||||||
|
secret: masterPassword,
|
||||||
|
} as MasterPasswordVerification,
|
||||||
|
userId,
|
||||||
|
component.activeAccount!.email,
|
||||||
|
);
|
||||||
|
expect(mockKeyService.setUserKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not unlock when valid password but user have no user key", async () => {
|
||||||
|
mockMasterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await component.unlockViaMasterPassword();
|
||||||
|
|
||||||
|
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
title: "Error Occurred",
|
||||||
|
message: "Invalid Master Password",
|
||||||
|
});
|
||||||
|
expect(mockMasterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
|
||||||
|
mockMasterKey,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
expect(mockKeyService.setUserKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should unlock and set user key and sync when valid password", async () => {
|
||||||
|
await component.unlockViaMasterPassword();
|
||||||
|
|
||||||
|
assertUnlocked();
|
||||||
|
expect(mockRouter.navigateByUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[false, undefined, false],
|
||||||
|
[false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false],
|
||||||
|
[false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true],
|
||||||
|
[true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false],
|
||||||
|
[false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true],
|
||||||
|
])(
|
||||||
|
"should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy set during user verification by master password",
|
||||||
|
async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => {
|
||||||
|
mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({
|
||||||
|
...masterPasswordVerificationResponse,
|
||||||
|
policyOptions:
|
||||||
|
masterPasswordPolicyOptions != null
|
||||||
|
? new MasterPasswordPolicyResponse({
|
||||||
|
EnforceOnLogin: masterPasswordPolicyOptions.enforceOnLogin,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
} as MasterPasswordVerificationResponse);
|
||||||
|
const passwordStrengthResult = { score: 1 } as ZXCVBNResult;
|
||||||
|
mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult);
|
||||||
|
mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword);
|
||||||
|
|
||||||
|
await component.unlockViaMasterPassword();
|
||||||
|
|
||||||
|
assertUnlocked();
|
||||||
|
if (masterPasswordPolicyOptions?.enforceOnLogin) {
|
||||||
|
expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith(
|
||||||
|
masterPassword,
|
||||||
|
component.activeAccount!.email,
|
||||||
|
);
|
||||||
|
expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith(
|
||||||
|
passwordStrengthResult.score,
|
||||||
|
masterPassword,
|
||||||
|
masterPasswordPolicyOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (forceSetPassword) {
|
||||||
|
expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||||
|
ForceSetPasswordReason.WeakMasterPassword,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[false, undefined, false],
|
||||||
|
[false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false],
|
||||||
|
[false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true],
|
||||||
|
[true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false],
|
||||||
|
[false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true],
|
||||||
|
])(
|
||||||
|
"should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy loaded from policy service",
|
||||||
|
async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => {
|
||||||
|
mockPolicyService.masterPasswordPolicyOptions$.mockReturnValue(
|
||||||
|
of(masterPasswordPolicyOptions),
|
||||||
|
);
|
||||||
|
const passwordStrengthResult = { score: 1 } as ZXCVBNResult;
|
||||||
|
mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult);
|
||||||
|
mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword);
|
||||||
|
|
||||||
|
await component.unlockViaMasterPassword();
|
||||||
|
|
||||||
|
assertUnlocked();
|
||||||
|
expect(mockPolicyService.masterPasswordPolicyOptions$).toHaveBeenCalledWith(userId);
|
||||||
|
if (masterPasswordPolicyOptions?.enforceOnLogin) {
|
||||||
|
expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith(
|
||||||
|
masterPassword,
|
||||||
|
component.activeAccount!.email,
|
||||||
|
);
|
||||||
|
expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith(
|
||||||
|
passwordStrengthResult.score,
|
||||||
|
masterPassword,
|
||||||
|
masterPasswordPolicyOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (forceSetPassword) {
|
||||||
|
expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||||
|
ForceSetPasswordReason.WeakMasterPassword,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[true, ClientType.Browser],
|
||||||
|
[false, ClientType.Cli],
|
||||||
|
[false, ClientType.Desktop],
|
||||||
|
[false, ClientType.Web],
|
||||||
|
])(
|
||||||
|
"should unlock and navigate by url to previous url = %o when client type = %o and previous url was set",
|
||||||
|
async (shouldNavigate, clientType) => {
|
||||||
|
const previousUrl = "/test-url";
|
||||||
|
component.clientType = clientType;
|
||||||
|
mockLockComponentService.getPreviousUrl.mockReturnValue(previousUrl);
|
||||||
|
|
||||||
|
await component.unlockViaMasterPassword();
|
||||||
|
|
||||||
|
assertUnlocked();
|
||||||
|
if (shouldNavigate) {
|
||||||
|
expect(mockRouter.navigateByUrl).toHaveBeenCalledWith(previousUrl);
|
||||||
|
} else {
|
||||||
|
expect(mockRouter.navigateByUrl).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["/tabs/current", ClientType.Browser],
|
||||||
|
[undefined, ClientType.Cli],
|
||||||
|
["vault", ClientType.Desktop],
|
||||||
|
["vault", ClientType.Web],
|
||||||
|
])(
|
||||||
|
"should unlock and navigate to success url = %o when client type = %o",
|
||||||
|
async (navigateUrl, clientType) => {
|
||||||
|
component.clientType = clientType;
|
||||||
|
mockLockComponentService.getPreviousUrl.mockReturnValue(null);
|
||||||
|
|
||||||
|
await component.unlockViaMasterPassword();
|
||||||
|
|
||||||
|
assertUnlocked();
|
||||||
|
expect(mockRouter.navigate).toHaveBeenCalledWith([navigateUrl]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("should unlock and close browser extension popout on firefox extension", async () => {
|
||||||
|
component.shouldClosePopout = true;
|
||||||
|
mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension);
|
||||||
|
|
||||||
|
await component.unlockViaMasterPassword();
|
||||||
|
|
||||||
|
assertUnlocked();
|
||||||
|
expect(mockLockComponentService.closeBrowserExtensionPopout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertUnlocked() {
|
||||||
|
expect(mockToastService.showToast).not.toHaveBeenCalled();
|
||||||
|
expect(mockMasterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
|
||||||
|
mockMasterKey,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
expect(mockKeyService.setUserKey).toHaveBeenCalledWith(mockUserKey, userId);
|
||||||
|
expect(mockDeviceTrustService.trustDeviceIfRequired).toHaveBeenCalledWith(userId);
|
||||||
|
expect(mockBiometricStateService.resetUserPromptCancelled).toHaveBeenCalled();
|
||||||
|
expect(mockMessagingService.send).toHaveBeenCalledWith("unlocked");
|
||||||
|
expect(mockSyncService.fullSync).toHaveBeenCalledWith(false);
|
||||||
|
expect(mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("logOut", () => {
|
||||||
|
it("should log out user and redirect to login page when dialog confirmed", async () => {
|
||||||
|
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||||
|
component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$);
|
||||||
|
|
||||||
|
await component.logOut();
|
||||||
|
|
||||||
|
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||||
|
title: { key: "logOut" },
|
||||||
|
content: { key: "logOutConfirmation" },
|
||||||
|
acceptButtonText: { key: "logOut" },
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
expect(mockLogoutService.logout).toHaveBeenCalledWith(userId);
|
||||||
|
expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not log out user when dialog cancelled", async () => {
|
||||||
|
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||||
|
component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$);
|
||||||
|
|
||||||
|
await component.logOut();
|
||||||
|
|
||||||
|
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||||
|
title: { key: "logOut" },
|
||||||
|
content: { key: "logOutConfirmation" },
|
||||||
|
acceptButtonText: { key: "logOut" },
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
expect(mockLogoutService.logout).not.toHaveBeenCalled();
|
||||||
|
expect(mockRouter.navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not log out user when user already logged out", async () => {
|
||||||
|
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||||
|
component.activeAccount = null;
|
||||||
|
|
||||||
|
await component.logOut();
|
||||||
|
|
||||||
|
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||||
|
title: { key: "logOut" },
|
||||||
|
content: { key: "logOutConfirmation" },
|
||||||
|
acceptButtonText: { key: "logOut" },
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
expect(mockLogoutService.logout).not.toHaveBeenCalled();
|
||||||
|
expect(mockRouter.navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,6 +74,7 @@ const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
|
|||||||
/// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible
|
/// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible
|
||||||
/// Fixes safari autoprompt behavior
|
/// Fixes safari autoprompt behavior
|
||||||
const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
|
const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "bit-lock",
|
selector: "bit-lock",
|
||||||
templateUrl: "lock.component.html",
|
templateUrl: "lock.component.html",
|
||||||
@@ -123,7 +124,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
formGroup: FormGroup | null = null;
|
formGroup: FormGroup | null = null;
|
||||||
|
|
||||||
// Browser extension properties:
|
// Browser extension properties:
|
||||||
private shouldClosePopout = false;
|
shouldClosePopout = false;
|
||||||
|
|
||||||
// Desktop properties:
|
// Desktop properties:
|
||||||
private deferFocus: boolean | null = null;
|
private deferFocus: boolean | null = null;
|
||||||
@@ -154,13 +155,10 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
|
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
|
||||||
|
|
||||||
private biometricService: BiometricsService,
|
private biometricService: BiometricsService,
|
||||||
private logoutService: LogoutService,
|
private logoutService: LogoutService,
|
||||||
|
|
||||||
private lockComponentService: LockComponentService,
|
private lockComponentService: LockComponentService,
|
||||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||||
|
|
||||||
// desktop deps
|
// desktop deps
|
||||||
private broadcasterService: BroadcasterService,
|
private broadcasterService: BroadcasterService,
|
||||||
) {}
|
) {}
|
||||||
@@ -211,7 +209,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildMasterPasswordForm() {
|
buildMasterPasswordForm() {
|
||||||
this.formGroup = this.formBuilder.group(
|
this.formGroup = this.formBuilder.group(
|
||||||
{
|
{
|
||||||
masterPassword: ["", [Validators.required]],
|
masterPassword: ["", [Validators.required]],
|
||||||
@@ -512,7 +510,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async unlockViaMasterPassword() {
|
async unlockViaMasterPassword() {
|
||||||
if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) {
|
if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user