mirror of
https://github.com/bitwarden/browser
synced 2025-12-24 04:04:24 +00:00
* [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$
418 lines
14 KiB
TypeScript
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]));
|
|
}
|