mirror of
https://github.com/bitwarden/browser
synced 2026-01-01 16:13:27 +00:00
* [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
288 lines
11 KiB
TypeScript
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);
|
|
}));
|
|
});
|
|
});
|