1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-24 04:04:24 +00:00
Files
browser/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts
Shane Melton f059d136b2 [PM-8485] [PM-7683] Dynamic list items - Org Details (#9466)
* [PM-7683] Add fullAddressForCopy helper to identity.view

* [PM-7683] Introduce CopyCipherFieldService to the Vault library

- A new CopyCipherFieldService that can be used to copy a cipher's field to the user clipboard
- A new appCopyField directive to make it easy to copy a cipher's fields in templates
- Tests for the CopyCipherFieldService

* [PM-7683] Introduce item-copy-actions.component

* [PM-7683] Fix username value in copy cipher directive

* [PM-7683] Add title to View item link

* [PM-8456] Introduce initial item-more-options.component

* [PM-8456] Add logic to show/hide login menu options

* [PM-8456] Implement favorite/unfavorite menu option

* [PM-8456] Implement clone menu option

* [PM-8456] Hide launch website instead of disabling it

* [PM-8456] Ensure cipherList observable updates on cipher changes

* [PM-7683] Move disabled logic into own method

* [PM-8456] Cleanup spec file to use Angular testbed

* [PM-8456] Fix more options tooltip

* [PM-8485] Introduce new PopupCipherView

* [PM-8485] Use new PopupCipherView in items service

* [PM-8485] Add org icon for items that belong to an organization

* [PM-8485] Fix tests

* [PM-8485] Remove share operator from cipherViews$
2024-06-04 14:34:48 -07:00

418 lines
14 KiB
TypeScript

import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { VaultPopupItemsService } from "./vault-popup-items.service";
import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service";
describe("VaultPopupItemsService", () => {
let testBed: TestBed;
let service: VaultPopupItemsService;
let allCiphers: Record<CipherId, CipherView>;
let autoFillCiphers: CipherView[];
let mockOrg: Organization;
let mockCollections: CollectionView[];
const cipherServiceMock = mock<CipherService>();
const vaultSettingsServiceMock = mock<VaultSettingsService>();
const organizationServiceMock = mock<OrganizationService>();
const vaultPopupListFiltersServiceMock = mock<VaultPopupListFiltersService>();
const searchService = mock<SearchService>();
const collectionService = mock<CollectionService>();
beforeEach(() => {
allCiphers = cipherFactory(10);
const cipherList = Object.values(allCiphers);
// First 2 ciphers are autofill
autoFillCiphers = cipherList.slice(0, 2);
// First autofill cipher is also favorite
autoFillCiphers[0].favorite = true;
// 3rd and 4th ciphers are favorite
cipherList[2].favorite = true;
cipherList[3].favorite = true;
cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList);
cipherServiceMock.ciphers$ = new BehaviorSubject(null).asObservable();
searchService.searchCiphers.mockImplementation(async (_, __, ciphers) => ciphers);
cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) =>
ciphers.filter((c) => ["0", "1"].includes(c.id)),
);
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false);
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false);
vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({
organization: null,
collection: null,
cipherType: null,
folder: null,
});
// Return all ciphers, `filterFunction$` will be tested in `VaultPopupListFiltersService`
vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject(
(ciphers: CipherView[]) => ciphers,
);
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
jest
.spyOn(BrowserApi, "getTabFromCurrentWindow")
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab);
mockOrg = {
id: "org1",
name: "Organization 1",
planProductType: ProductType.Enterprise,
} as Organization;
mockCollections = [
{ id: "col1", name: "Collection 1" } as CollectionView,
{ id: "col2", name: "Collection 2" } as CollectionView,
];
organizationServiceMock.organizations$ = new BehaviorSubject([mockOrg]);
collectionService.decryptedCollections$ = new BehaviorSubject(mockCollections);
testBed = TestBed.configureTestingModule({
providers: [
{ provide: CipherService, useValue: cipherServiceMock },
{ provide: VaultSettingsService, useValue: vaultSettingsServiceMock },
{ provide: SearchService, useValue: searchService },
{ provide: OrganizationService, useValue: organizationServiceMock },
{ provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock },
{ provide: CollectionService, useValue: collectionService },
],
});
service = testBed.inject(VaultPopupItemsService);
});
afterEach(() => {
jest.clearAllMocks();
});
it("should be created", () => {
service = testBed.inject(VaultPopupItemsService);
expect(service).toBeTruthy();
});
it("should merge cipher views with collections and organization", (done) => {
const cipherList = Object.values(allCiphers);
cipherList[0].organizationId = "org1";
cipherList[0].collectionIds = ["col1", "col2"];
service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers[0].organization).toEqual(mockOrg);
expect(ciphers[0].collections).toContain(mockCollections[0]);
expect(ciphers[0].collections).toContain(mockCollections[1]);
done();
});
});
describe("autoFillCiphers$", () => {
it("should return empty array if there is no current tab", (done) => {
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers).toEqual([]);
done();
});
});
it("should return empty array if in Popout window", (done) => {
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers).toEqual([]);
done();
});
});
it("should filter ciphers for the current tab and types", (done) => {
const currentTab = { url: "https://example.com" } as chrome.tabs.Tab;
(vaultSettingsServiceMock.showCardsCurrentTab$ as BehaviorSubject<boolean>).next(true);
(vaultSettingsServiceMock.showIdentitiesCurrentTab$ as BehaviorSubject<boolean>).next(true);
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab);
service.autoFillCiphers$.subscribe((ciphers) => {
expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1);
expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith(
expect.anything(),
currentTab.url,
[CipherType.Card, CipherType.Identity],
);
done();
});
});
it("should return ciphers sorted by type, then by last used date, then by name", (done) => {
const expectedTypeOrder: Record<CipherType, number> = {
[CipherType.Login]: 1,
[CipherType.Card]: 2,
[CipherType.Identity]: 3,
[CipherType.SecureNote]: 4,
};
// Assume all ciphers are autofill ciphers to test sorting
cipherServiceMock.filterCiphersForUrl.mockImplementation(async () =>
Object.values(allCiphers),
);
service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers.length).toBe(10);
for (let i = 0; i < ciphers.length - 1; i++) {
const current = ciphers[i];
const next = ciphers[i + 1];
expect(expectedTypeOrder[current.type]).toBeLessThanOrEqual(expectedTypeOrder[next.type]);
}
expect(cipherServiceMock.sortCiphersByLastUsedThenName).toHaveBeenCalled();
done();
});
});
it("should filter autoFillCiphers$ down to search term", (done) => {
const searchText = "Login";
searchService.searchCiphers.mockImplementation(async (q, _, ciphers) => {
return ciphers.filter((cipher) => {
return cipher.name.includes(searchText);
});
});
// there is only 1 Login returned for filteredCiphers.
service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers[0].name.includes(searchText)).toBe(true);
expect(ciphers.length).toBe(1);
done();
});
});
});
describe("favoriteCiphers$", () => {
it("should exclude autofill ciphers", (done) => {
service.favoriteCiphers$.subscribe((ciphers) => {
// 2 autofill ciphers, 3 favorite ciphers, 1 favorite cipher is also autofill = 2 favorite ciphers to show
expect(ciphers.length).toBe(2);
done();
});
});
it("should sort by last used then by name", (done) => {
service.favoriteCiphers$.subscribe((ciphers) => {
expect(cipherServiceMock.sortCiphersByLastUsedThenName).toHaveBeenCalled();
done();
});
});
it("should filter favoriteCiphers$ down to search term", (done) => {
const cipherList = Object.values(allCiphers);
const searchText = "Card 2";
searchService.searchCiphers.mockImplementation(async () => {
return cipherList.filter((cipher) => {
return cipher.name === searchText;
});
});
service.favoriteCiphers$.subscribe((ciphers) => {
// There are 2 favorite items but only one Card 2
expect(ciphers[0].name).toBe(searchText);
expect(ciphers.length).toBe(1);
done();
});
});
});
describe("remainingCiphers$", () => {
it("should exclude autofill and favorite ciphers", (done) => {
service.remainingCiphers$.subscribe((ciphers) => {
// 2 autofill ciphers, 2 favorite ciphers = 6 remaining ciphers to show
expect(ciphers.length).toBe(6);
done();
});
});
it("should sort by last used then by name", (done) => {
service.remainingCiphers$.subscribe((ciphers) => {
expect(cipherServiceMock.getLocaleSortingFunction).toHaveBeenCalled();
done();
});
});
it("should filter remainingCiphers$ down to search term", (done) => {
const cipherList = Object.values(allCiphers);
const searchText = "Login";
searchService.searchCiphers.mockImplementation(async () => {
return cipherList.filter((cipher) => {
return cipher.name.includes(searchText);
});
});
service.remainingCiphers$.subscribe((ciphers) => {
// There are 6 remaining ciphers but only 2 with "Login" in the name
expect(ciphers.length).toBe(2);
done();
});
});
});
describe("emptyVault$", () => {
it("should return true if there are no ciphers", (done) => {
cipherServiceMock.getAllDecrypted.mockResolvedValue([]);
service.emptyVault$.subscribe((empty) => {
expect(empty).toBe(true);
done();
});
});
it("should return false if there are ciphers", (done) => {
service.emptyVault$.subscribe((empty) => {
expect(empty).toBe(false);
done();
});
});
});
describe("autoFillAllowed$", () => {
it("should return true if there is a current tab", (done) => {
service.autofillAllowed$.subscribe((allowed) => {
expect(allowed).toBe(true);
done();
});
});
it("should return false if there is no current tab", (done) => {
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
service.autofillAllowed$.subscribe((allowed) => {
expect(allowed).toBe(false);
done();
});
});
it("should return false if in a Popout", (done) => {
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
service.autofillAllowed$.subscribe((allowed) => {
expect(allowed).toBe(false);
done();
});
});
});
describe("noFilteredResults$", () => {
it("should return false when filteredResults has values", (done) => {
service.noFilteredResults$.subscribe((noResults) => {
expect(noResults).toBe(false);
done();
});
});
it("should return true when there are zero filteredResults", (done) => {
searchService.searchCiphers.mockImplementation(async () => []);
service.noFilteredResults$.subscribe((noResults) => {
expect(noResults).toBe(true);
done();
});
});
});
describe("hasFilterApplied$", () => {
it("should return true if the search term provided is searchable", (done) => {
searchService.isSearchable.mockImplementation(async () => true);
service.hasFilterApplied$.subscribe((canSearch) => {
expect(canSearch).toBe(true);
done();
});
});
it("should return false if the search term provided is not searchable", (done) => {
searchService.isSearchable.mockImplementation(async () => false);
service.hasFilterApplied$.subscribe((canSearch) => {
expect(canSearch).toBe(false);
done();
});
});
});
describe("applyFilter", () => {
it("should call search Service with the new search term", (done) => {
const searchText = "Hello";
service.applyFilter(searchText);
const searchServiceSpy = jest.spyOn(searchService, "searchCiphers");
service.favoriteCiphers$.subscribe(() => {
expect(searchServiceSpy).toHaveBeenCalledWith(searchText, null, expect.anything());
done();
});
});
});
});
// A function to generate a list of ciphers of different types
function cipherFactory(count: number): Record<CipherId, CipherView> {
const ciphers: CipherView[] = [];
for (let i = 0; i < count; i++) {
const type = ((i % 4) + 1) as CipherType;
switch (type) {
case CipherType.Login:
ciphers.push({
id: `${i}`,
type: CipherType.Login,
name: `Login ${i}`,
login: {
username: `username${i}`,
password: `password${i}`,
},
} as CipherView);
break;
case CipherType.SecureNote:
ciphers.push({
id: `${i}`,
type: CipherType.SecureNote,
name: `SecureNote ${i}`,
notes: `notes${i}`,
} as CipherView);
break;
case CipherType.Card:
ciphers.push({
id: `${i}`,
type: CipherType.Card,
name: `Card ${i}`,
card: {
cardholderName: `cardholderName${i}`,
number: `number${i}`,
brand: `brand${i}`,
},
} as CipherView);
break;
case CipherType.Identity:
ciphers.push({
id: `${i}`,
type: CipherType.Identity,
name: `Identity ${i}`,
identity: {
firstName: `firstName${i}`,
lastName: `lastName${i}`,
},
} as CipherView);
break;
}
}
return Object.fromEntries(ciphers.map((c) => [c.id, c]));
}