import { mock, MockProxy } from "jest-mock-extended"; import { firstValueFrom, of, ReplaySubject } from "rxjs"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { FakeStateProvider, makeEncString, makeSymmetricCryptoKey, mockAccountServiceWith, } from "@bitwarden/common/spec"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; import { CollectionData } from "../models"; import { DefaultvNextCollectionService } from "./default-vnext-collection.service"; import { ENCRYPTED_COLLECTION_DATA_KEY } from "./vnext-collection.state"; describe("DefaultvNextCollectionService", () => { let keyService: MockProxy; let encryptService: MockProxy; let i18nService: MockProxy; let stateProvider: FakeStateProvider; let userId: UserId; let cryptoKeys: ReplaySubject | null>; let collectionService: DefaultvNextCollectionService; beforeEach(() => { userId = Utils.newGuid() as UserId; keyService = mock(); encryptService = mock(); i18nService = mock(); stateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); cryptoKeys = new ReplaySubject(1); keyService.orgKeys$.mockReturnValue(cryptoKeys); // Set up mock decryption encryptService.decryptToUtf8 .calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey), expect.any(String)) .mockImplementation((encString, key) => Promise.resolve(encString.data.replace("ENC_", "DEC_")), ); (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); // Arrange i18nService so that sorting algorithm doesn't throw i18nService.collator = null; collectionService = new DefaultvNextCollectionService( keyService, encryptService, i18nService, stateProvider, ); }); afterEach(() => { delete (window as any).bitwardenContainerService; }); describe("decryptedCollections$", () => { it("emits decrypted collections from state", async () => { // Arrange test data const org1 = Utils.newGuid() as OrganizationId; const orgKey1 = makeSymmetricCryptoKey(64, 1); const collection1 = collectionDataFactory(org1); const org2 = Utils.newGuid() as OrganizationId; const orgKey2 = makeSymmetricCryptoKey(64, 2); const collection2 = collectionDataFactory(org2); // Arrange dependencies await setEncryptedState([collection1, collection2]); cryptoKeys.next({ [org1]: orgKey1, [org2]: orgKey2, }); const result = await firstValueFrom(collectionService.decryptedCollections$(of(userId))); // Assert emitted values expect(result.length).toBe(2); expect(result).toIncludeAllPartialMembers([ { id: collection1.id, name: "DEC_NAME_" + collection1.id, }, { id: collection2.id, name: "DEC_NAME_" + collection2.id, }, ]); // Assert that the correct org keys were used for each encrypted string expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( expect.objectContaining(new EncString(collection1.name)), orgKey1, expect.any(String), ); expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( expect.objectContaining(new EncString(collection2.name)), orgKey2, expect.any(String), ); }); it("handles null collection state", async () => { // Arrange dependencies await setEncryptedState(null); cryptoKeys.next({}); const encryptedCollections = await firstValueFrom( collectionService.encryptedCollections$(of(userId)), ); expect(encryptedCollections.length).toBe(0); }); }); describe("encryptedCollections$", () => { it("emits encrypted collections from state", async () => { // Arrange test data const collection1 = collectionDataFactory(); const collection2 = collectionDataFactory(); // Arrange dependencies await setEncryptedState([collection1, collection2]); const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); expect(result.length).toBe(2); expect(result).toIncludeAllPartialMembers([ { id: collection1.id, name: makeEncString("ENC_NAME_" + collection1.id), }, { id: collection2.id, name: makeEncString("ENC_NAME_" + collection2.id), }, ]); }); it("handles null collection state", async () => { await setEncryptedState(null); const decryptedCollections = await firstValueFrom( collectionService.encryptedCollections$(of(userId)), ); expect(decryptedCollections.length).toBe(0); }); }); describe("upsert", () => { it("upserts to existing collections", async () => { const collection1 = collectionDataFactory(); const collection2 = collectionDataFactory(); await setEncryptedState([collection1, collection2]); const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, { name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString, }); const newCollection3 = collectionDataFactory(); await collectionService.upsert([updatedCollection1, newCollection3], userId); const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); expect(result.length).toBe(3); expect(result).toIncludeAllPartialMembers([ { id: collection1.id, name: makeEncString("UPDATED_ENC_NAME_" + collection1.id), }, { id: collection2.id, name: makeEncString("ENC_NAME_" + collection2.id), }, { id: newCollection3.id, name: makeEncString("ENC_NAME_" + newCollection3.id), }, ]); }); it("upserts to a null state", async () => { const collection1 = collectionDataFactory(); await setEncryptedState(null); await collectionService.upsert(collection1, userId); const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); expect(result.length).toBe(1); expect(result).toIncludeAllPartialMembers([ { id: collection1.id, name: makeEncString("ENC_NAME_" + collection1.id), }, ]); }); }); describe("replace", () => { it("replaces all collections", async () => { await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); const newCollection3 = collectionDataFactory(); await collectionService.replace( { [newCollection3.id]: newCollection3, }, userId, ); const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); expect(result.length).toBe(1); expect(result).toIncludeAllPartialMembers([ { id: newCollection3.id, name: makeEncString("ENC_NAME_" + newCollection3.id), }, ]); }); }); it("clearDecryptedState", async () => { await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); await collectionService.clearDecryptedState(userId); // Encrypted state remains const encryptedState = await firstValueFrom( collectionService.encryptedCollections$(of(userId)), ); expect(encryptedState.length).toEqual(2); // Decrypted state is cleared const decryptedState = await firstValueFrom( collectionService.decryptedCollections$(of(userId)), ); expect(decryptedState.length).toEqual(0); }); it("clear", async () => { await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); cryptoKeys.next({}); await collectionService.clear(userId); // Encrypted state is cleared const encryptedState = await firstValueFrom( collectionService.encryptedCollections$(of(userId)), ); expect(encryptedState.length).toEqual(0); // Decrypted state is cleared const decryptedState = await firstValueFrom( collectionService.decryptedCollections$(of(userId)), ); expect(decryptedState.length).toEqual(0); }); describe("delete", () => { it("deletes a collection", async () => { const collection1 = collectionDataFactory(); const collection2 = collectionDataFactory(); await setEncryptedState([collection1, collection2]); await collectionService.delete(collection1.id, userId); const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); expect(result.length).toEqual(1); expect(result[0]).toMatchObject({ id: collection2.id }); }); it("deletes several collections", async () => { const collection1 = collectionDataFactory(); const collection2 = collectionDataFactory(); const collection3 = collectionDataFactory(); await setEncryptedState([collection1, collection2, collection3]); await collectionService.delete([collection1.id, collection3.id], userId); const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); expect(result.length).toEqual(1); expect(result[0]).toMatchObject({ id: collection2.id }); }); it("handles null collections", async () => { const collection1 = collectionDataFactory(); await setEncryptedState(null); await collectionService.delete(collection1.id, userId); const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); expect(result.length).toEqual(0); }); }); const setEncryptedState = (collectionData: CollectionData[] | null) => stateProvider.setUserState( ENCRYPTED_COLLECTION_DATA_KEY, collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])), userId, ); }); const collectionDataFactory = (orgId?: OrganizationId) => { const collection = new CollectionData({} as any); collection.id = Utils.newGuid() as CollectionId; collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId); collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString; return collection; };