1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-01 16:13:27 +00:00
Files
browser/libs/vault/src/cipher-view/cipher-view.component.spec.ts
Shane Melton 7e5f02f90c [PM-24469] Implement Risk Insights for Premium in Cipher view component (#17012)
* [PM-24469] Refactor CipherViewComponent to use Angular signals and computed properties for improved reactivity

* [PM-24469] Refactor CipherViewComponent to utilize Angular signals for organization data retrieval

* [PM-24469] Refactor CipherViewComponent to utilize Angular signals for folder data retrieval

* [PM-24469] Cleanup organization signal

* [PM-24469] Refactor CipherViewComponent to replace signal for card expiration with computed property

* [PM-24469] Improve collections loading in CipherViewComponent

* [PM-24469] Remove redundant loadCipherData method

* [PM-24469] Refactor CipherViewComponent to replace signal with computed property for pending change password tasks

* [PM-24469] Refactor LoginCredentialsViewComponent to rename hadPendingChangePasswordTask to showChangePasswordLink for clarity

* [PM-24469] Introduce showChangePasswordLink computed property for improved readability

* [PM-24469] Initial RI for premium logic

* [PM-24469] Refactor checkPassword risk checking logic

* [PM-24469] Cleanup premium check

* [PM-24469] Cleanup UI visuals

* [PM-24469] Fix missing typography import

* [PM-24469] Cleanup docs

* [PM-24469] Add feature flag

* [PM-24469] Ensure password risk check is only performed when the feature is enabled, and the cipher is editable by the user, and it has a password

* [PM-24469] Refactor password risk evaluation logic and add unit tests for risk assessment

* [PM-24469] Fix mismatched CipherId type

* [PM-24469] Fix test dependencies

* [PM-24469] Fix config service mock in emergency view dialog spec

* [PM-24469] Wait for decrypted vault before calculating cipher risk

* [PM-24469] startWith(false) for passwordIsAtRisk signal to avoid showing stale values when cipher changes

* [PM-24469] Exclude organization owned ciphers from JIT risk analysis

* [PM-24469] Add initial cipher-view component test boilerplate

* [PM-24469] Add passwordIsAtRisk signal tests

* [PM-24469] Ignore soft deleted items for RI for premium feature

* [PM-24469] Fix tests
2025-11-04 12:15:53 -08:00

288 lines
11 KiB
TypeScript

import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service";
import { CipherViewComponent } from "./cipher-view.component";
describe("CipherViewComponent", () => {
let component: CipherViewComponent;
let fixture: ComponentFixture<CipherViewComponent>;
// Mock services
let mockAccountService: AccountService;
let mockOrganizationService: OrganizationService;
let mockCollectionService: CollectionService;
let mockFolderService: FolderService;
let mockTaskService: TaskService;
let mockPlatformUtilsService: PlatformUtilsService;
let mockChangeLoginPasswordService: ChangeLoginPasswordService;
let mockCipherService: CipherService;
let mockViewPasswordHistoryService: ViewPasswordHistoryService;
let mockI18nService: I18nService;
let mockLogService: LogService;
let mockCipherRiskService: CipherRiskService;
let mockBillingAccountProfileStateService: BillingAccountProfileStateService;
let mockConfigService: ConfigService;
// Mock data
let mockCipherView: CipherView;
let featureFlagEnabled$: BehaviorSubject<boolean>;
let hasPremiumFromAnySource$: BehaviorSubject<boolean>;
let activeAccount$: BehaviorSubject<Account>;
beforeEach(async () => {
// Setup mock observables
activeAccount$ = new BehaviorSubject({
id: "test-user-id",
email: "test@example.com",
} as Account);
featureFlagEnabled$ = new BehaviorSubject(false);
hasPremiumFromAnySource$ = new BehaviorSubject(true);
// Create service mocks
mockAccountService = mock<AccountService>();
mockAccountService.activeAccount$ = activeAccount$;
mockOrganizationService = mock<OrganizationService>();
mockCollectionService = mock<CollectionService>();
mockFolderService = mock<FolderService>();
mockTaskService = mock<TaskService>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
mockChangeLoginPasswordService = mock<ChangeLoginPasswordService>();
mockCipherService = mock<CipherService>();
mockViewPasswordHistoryService = mock<ViewPasswordHistoryService>();
mockI18nService = mock<I18nService>({
t: (key: string) => key,
});
mockLogService = mock<LogService>();
mockCipherRiskService = mock<CipherRiskService>();
mockBillingAccountProfileStateService = mock<BillingAccountProfileStateService>();
mockBillingAccountProfileStateService.hasPremiumFromAnySource$ = jest
.fn()
.mockReturnValue(hasPremiumFromAnySource$);
mockConfigService = mock<ConfigService>();
mockConfigService.getFeatureFlag$ = jest.fn().mockReturnValue(featureFlagEnabled$);
// Setup mock cipher view
mockCipherView = new CipherView();
mockCipherView.id = "cipher-id";
mockCipherView.name = "Test Cipher";
await TestBed.configureTestingModule({
imports: [CipherViewComponent],
providers: [
{ provide: AccountService, useValue: mockAccountService },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: CollectionService, useValue: mockCollectionService },
{ provide: FolderService, useValue: mockFolderService },
{ provide: TaskService, useValue: mockTaskService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: ChangeLoginPasswordService, useValue: mockChangeLoginPasswordService },
{ provide: CipherService, useValue: mockCipherService },
{ provide: ViewPasswordHistoryService, useValue: mockViewPasswordHistoryService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: LogService, useValue: mockLogService },
{ provide: CipherRiskService, useValue: mockCipherRiskService },
{
provide: BillingAccountProfileStateService,
useValue: mockBillingAccountProfileStateService,
},
{ provide: ConfigService, useValue: mockConfigService },
],
schemas: [NO_ERRORS_SCHEMA],
})
// Override the component template to avoid rendering child components
// Allows testing component logic without
// needing to provide dependencies for all child components.
.overrideComponent(CipherViewComponent, {
set: {
template: "<div>{{ passwordIsAtRisk() }}</div>",
imports: [],
},
})
.compileComponents();
fixture = TestBed.createComponent(CipherViewComponent);
component = fixture.componentInstance;
});
describe("passwordIsAtRisk signal", () => {
// Helper to create a cipher view with login credentials
const createLoginCipherView = (): CipherView => {
const cipher = new CipherView();
cipher.id = "cipher-id";
cipher.name = "Test Login";
cipher.type = CipherType.Login;
cipher.edit = true;
cipher.organizationId = undefined;
// Set up login with password so hasLoginPassword returns true
cipher.login = { password: "test-password" } as any;
return cipher;
};
beforeEach(() => {
// Reset observables to default values for this test suite
featureFlagEnabled$.next(true);
hasPremiumFromAnySource$.next(true);
// Setup default mock for computeCipherRiskForUser (individual tests can override)
mockCipherRiskService.computeCipherRiskForUser = jest.fn().mockResolvedValue({
password_strength: 4,
exposed_result: { type: "NotFound" },
reuse_count: 1,
});
// Recreate the fixture for each test in this suite.
// This ensures that the signal's observable subscribes with the correct
// initial state
fixture = TestBed.createComponent(CipherViewComponent);
component = fixture.componentInstance;
});
it("returns false when feature flag is disabled", fakeAsync(() => {
featureFlagEnabled$.next(false);
const cipher = createLoginCipherView();
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns false when cipher has no login password", fakeAsync(() => {
const cipher = createLoginCipherView();
cipher.login = {} as any; // No password
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns false when user does not have edit access", fakeAsync(() => {
const cipher = createLoginCipherView();
cipher.edit = false;
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns false when cipher is deleted", fakeAsync(() => {
const cipher = createLoginCipherView();
cipher.deletedDate = new Date();
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns false for organization-owned ciphers", fakeAsync(() => {
const cipher = createLoginCipherView();
cipher.organizationId = "org-id";
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns false when user is not premium", fakeAsync(() => {
hasPremiumFromAnySource$.next(false);
const cipher = createLoginCipherView();
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
tick();
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
it("returns true when password is weak", fakeAsync(() => {
// Setup mock to return weak password
const mockRiskyResult = {
password_strength: 2, // Weak password (< 3)
exposed_result: { type: "NotFound" },
reuse_count: 1,
};
mockCipherRiskService.computeCipherRiskForUser = jest.fn().mockResolvedValue(mockRiskyResult);
const cipher = createLoginCipherView();
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
// Initial value should be false (from startWith(false))
expect(component.passwordIsAtRisk()).toBe(false);
// Wait for async operations to complete
tick();
fixture.detectChanges();
// After async completes, should reflect the weak password
expect(mockCipherRiskService.computeCipherRiskForUser).toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(true);
}));
it("returns false when password is strong and not exposed", fakeAsync(() => {
// Setup mock to return safe password
const mockSafeResult = {
password_strength: 4, // Strong password
exposed_result: { type: "NotFound" }, // Not exposed
reuse_count: 1, // Not reused
};
mockCipherRiskService.computeCipherRiskForUser = jest.fn().mockResolvedValue(mockSafeResult);
const cipher = createLoginCipherView();
fixture.componentRef.setInput("cipher", cipher);
fixture.detectChanges();
// Initial value should be false
expect(component.passwordIsAtRisk()).toBe(false);
// Wait for async operations to complete
tick();
fixture.detectChanges();
// Should remain false for safe password
expect(mockCipherRiskService.computeCipherRiskForUser).toHaveBeenCalled();
expect(component.passwordIsAtRisk()).toBe(false);
}));
});
});