mirror of
https://github.com/bitwarden/browser
synced 2026-01-06 10:33:57 +00:00
[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
This commit is contained in:
287
libs/vault/src/cipher-view/cipher-view.component.spec.ts
Normal file
287
libs/vault/src/cipher-view/cipher-view.component.spec.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
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);
|
||||
}));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user