1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00
Files
browser/libs/common/src/vault/services/cipher.service.spec.ts
Daniel Riera 7dcd3fa603 [PM-26302] Extension when launching a website from a vault item autofill fills wrong cipher (#16810)
* PM-26302 create helper that will assign local data to ciphers and cached ciphers on decryption

* remove helper and call local data only on get cipher for url to make operation less expensive

* add tests for local data using functions that call the getcipherforurl function

* reorder to have early null check
2025-10-20 10:34:21 -04:00

998 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { mock } from "jest-mock-extended";
import { BehaviorSubject, filter, firstValueFrom, map, of } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.response";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CipherDecryptionKeys, KeyService } from "@bitwarden/key-management";
import { MessageSender } from "@bitwarden/messaging";
import { CipherListView } from "@bitwarden/sdk-internal";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeStateProvider } from "../../../spec/fake-state-provider";
import { makeStaticByteArray, makeSymmetricCryptoKey } from "../../../spec/utils";
import { ApiService } from "../../abstractions/api.service";
import { AutofillSettingsService } from "../../autofill/services/autofill-settings.service";
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../key-management/crypto/models/enc-string";
import { UriMatchStrategy } from "../../models/domain/domain-service";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { Utils } from "../../platform/misc/utils";
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { ContainerService } from "../../platform/services/container.service";
import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid";
import { CipherKey, OrgKey, UserKey } from "../../types/key";
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
import { EncryptionContext } from "../abstractions/cipher.service";
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
import { SearchService } from "../abstractions/search.service";
import { FieldType } from "../enums";
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
import { CipherType } from "../enums/cipher-type";
import { CipherPermissionsApi } from "../models/api/cipher-permissions.api";
import { CipherData } from "../models/data/cipher.data";
import { Cipher } from "../models/domain/cipher";
import { CipherCreateRequest } from "../models/request/cipher-create.request";
import { CipherPartialRequest } from "../models/request/cipher-partial.request";
import { CipherRequest } from "../models/request/cipher.request";
import { AttachmentView } from "../models/view/attachment.view";
import { CipherView } from "../models/view/cipher.view";
import { LoginUriView } from "../models/view/login-uri.view";
import { CipherService } from "./cipher.service";
import { ENCRYPTED_CIPHERS } from "./key-state/ciphers.state";
const ENCRYPTED_TEXT = "This data has been encrypted";
function encryptText(clearText: string | Uint8Array) {
return Promise.resolve(new EncString(`${clearText} has been encrypted`));
}
const ENCRYPTED_BYTES = mock<EncArrayBuffer>();
const cipherData: CipherData = {
id: "id",
organizationId: "orgId",
folderId: "folderId",
edit: true,
viewPassword: true,
organizationUseTotp: true,
favorite: false,
revisionDate: "2022-01-31T12:00:00.000Z",
type: CipherType.Login,
name: "EncryptedString",
notes: "EncryptedString",
creationDate: "2022-01-01T12:00:00.000Z",
deletedDate: null,
permissions: new CipherPermissionsApi(),
key: "EncKey",
archivedDate: null,
reprompt: CipherRepromptType.None,
login: {
uris: [
{ uri: "EncryptedString", uriChecksum: "EncryptedString", match: UriMatchStrategy.Domain },
],
username: "EncryptedString",
password: "EncryptedString",
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
totp: "EncryptedString",
autofillOnPageLoad: false,
},
passwordHistory: [{ password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" }],
attachments: [
{ id: "a1", url: "url", size: "1100", sizeName: "1.1 KB", fileName: "file", key: "EncKey" },
{ id: "a2", url: "url", size: "1100", sizeName: "1.1 KB", fileName: "file", key: "EncKey" },
],
fields: [
{ name: "EncryptedString", value: "EncryptedString", type: FieldType.Text, linkedId: null },
{ name: "EncryptedString", value: "EncryptedString", type: FieldType.Hidden, linkedId: null },
],
};
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
describe("Cipher Service", () => {
const keyService = mock<KeyService>();
const autofillSettingsService = mock<AutofillSettingsService>();
const domainSettingsService = mock<DomainSettingsService>();
const apiService = mock<ApiService>();
const cipherFileUploadService = mock<CipherFileUploadService>();
const i18nService = mock<I18nService>();
const searchService = mock<SearchService>();
const encryptService = mock<EncryptService>();
const configService = mock<ConfigService>();
accountService = mockAccountServiceWith(mockUserId);
const logService = mock<LogService>();
const stateProvider = new FakeStateProvider(accountService);
const cipherEncryptionService = mock<CipherEncryptionService>();
const messageSender = mock<MessageSender>();
const userId = "TestUserId" as UserId;
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId;
let cipherService: CipherService;
let encryptionContext: EncryptionContext;
beforeEach(() => {
encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES));
encryptService.encryptString.mockReturnValue(Promise.resolve(new EncString(ENCRYPTED_TEXT)));
// Mock i18nService collator
i18nService.collator = {
compare: jest.fn().mockImplementation((a: string, b: string) => a.localeCompare(b)),
resolvedOptions: jest.fn().mockReturnValue({}),
} as any;
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
cipherService = new CipherService(
keyService,
domainSettingsService,
apiService,
i18nService,
searchService,
autofillSettingsService,
encryptService,
cipherFileUploadService,
configService,
stateProvider,
accountService,
logService,
cipherEncryptionService,
messageSender,
);
encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId };
});
afterEach(() => {
jest.resetAllMocks();
});
describe("saveAttachmentRawWithServer()", () => {
it("should upload encrypted file contents with save attachments", async () => {
const fileName = "filename";
const fileData = new Uint8Array(10);
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
keyService.makeDataEncKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32))),
);
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false));
configService.getFeatureFlag
.calledWith(FeatureFlag.CipherKeyEncryption)
.mockResolvedValue(false);
const spy = jest.spyOn(cipherFileUploadService, "upload");
await cipherService.saveAttachmentRawWithServer(new Cipher(), fileName, fileData, userId);
expect(spy).toHaveBeenCalled();
});
});
describe("createWithServer()", () => {
it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => {
const spy = jest
.spyOn(apiService, "postCipherAdmin")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext, true);
const expectedObj = new CipherCreateRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(expectedObj);
});
it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => {
encryptionContext.cipher.organizationId = null!;
const spy = jest
.spyOn(apiService, "postCipher")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext, true);
const expectedObj = new CipherRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(expectedObj);
});
it("should call apiService.postCipherCreate if collectionsIds != null", async () => {
encryptionContext.cipher.collectionIds = ["123"];
const spy = jest
.spyOn(apiService, "postCipherCreate")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext);
const expectedObj = new CipherCreateRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(expectedObj);
});
it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => {
const spy = jest
.spyOn(apiService, "postCipher")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.createWithServer(encryptionContext);
const expectedObj = new CipherRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(expectedObj);
});
});
describe("updateWithServer()", () => {
it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => {
const spy = jest
.spyOn(apiService, "putCipherAdmin")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.updateWithServer(encryptionContext, true);
const expectedObj = new CipherRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
});
it("should call apiService.putCipher if cipher.edit is true", async () => {
encryptionContext.cipher.edit = true;
const spy = jest
.spyOn(apiService, "putCipher")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.updateWithServer(encryptionContext);
const expectedObj = new CipherRequest(encryptionContext);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
});
it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => {
encryptionContext.cipher.edit = false;
const spy = jest
.spyOn(apiService, "putPartialCipher")
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
await cipherService.updateWithServer(encryptionContext);
const expectedObj = new CipherPartialRequest(encryptionContext.cipher);
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
});
});
describe("encrypt", () => {
let cipherView: CipherView;
beforeEach(() => {
cipherView = new CipherView();
cipherView.type = CipherType.Login;
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(makeStaticByteArray(64)),
);
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
keyService.makeCipherKey.mockReturnValue(
Promise.resolve(new SymmetricCryptoKey(makeStaticByteArray(64)) as CipherKey),
);
encryptService.encryptString.mockImplementation(encryptText);
encryptService.wrapSymmetricKey.mockResolvedValue(new EncString("Re-encrypted Cipher Key"));
jest.spyOn(cipherService as any, "getAutofillOnPageLoadDefault").mockResolvedValue(true);
});
it("should call encrypt method of CipherEncryptionService when feature flag is true", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
.mockResolvedValue(true);
cipherEncryptionService.encrypt.mockResolvedValue(encryptionContext);
const result = await cipherService.encrypt(cipherView, userId);
expect(result).toEqual(encryptionContext);
expect(cipherEncryptionService.encrypt).toHaveBeenCalledWith(cipherView, userId);
});
it("should call legacy encrypt when feature flag is false", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
.mockResolvedValue(false);
jest.spyOn(cipherService as any, "encryptCipher").mockResolvedValue(encryptionContext.cipher);
const result = await cipherService.encrypt(cipherView, userId);
expect(result).toEqual(encryptionContext);
expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();
});
it("should call legacy encrypt when keys are provided", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
.mockResolvedValue(true);
jest.spyOn(cipherService as any, "encryptCipher").mockResolvedValue(encryptionContext.cipher);
const encryptKey = new SymmetricCryptoKey(new Uint8Array(32));
const decryptKey = new SymmetricCryptoKey(new Uint8Array(32));
let result = await cipherService.encrypt(cipherView, userId, encryptKey);
expect(result).toEqual(encryptionContext);
expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();
result = await cipherService.encrypt(cipherView, userId, undefined, decryptKey);
expect(result).toEqual(encryptionContext);
expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();
result = await cipherService.encrypt(cipherView, userId, encryptKey, decryptKey);
expect(result).toEqual(encryptionContext);
expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled();
});
it("should return the encrypting user id", async () => {
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
const { encryptedFor } = await cipherService.encrypt(cipherView, userId);
expect(encryptedFor).toEqual(userId);
});
describe("login encryption", () => {
it("should add a uri hash to login uris", async () => {
encryptService.hash.mockImplementation((value) => Promise.resolve(`${value} hash`));
cipherView.login.uris = [
{ uri: "uri", match: UriMatchStrategy.RegularExpression } as LoginUriView,
];
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
const { cipher } = await cipherService.encrypt(cipherView, userId);
expect(cipher.login.uris).toEqual([
{
uri: new EncString("uri has been encrypted"),
uriChecksum: new EncString("uri hash has been encrypted"),
match: UriMatchStrategy.RegularExpression,
},
]);
});
});
describe("cipher.key", () => {
beforeEach(() => {
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
});
it("is null when feature flag is false", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.CipherKeyEncryption)
.mockResolvedValue(false);
const { cipher } = await cipherService.encrypt(cipherView, userId);
expect(cipher.key).toBeNull();
});
describe("when feature flag is true", () => {
beforeEach(() => {
configService.getFeatureFlag
.calledWith(FeatureFlag.CipherKeyEncryption)
.mockResolvedValue(true);
});
it("is null when the cipher is not viewPassword", async () => {
cipherView.viewPassword = false;
const { cipher } = await cipherService.encrypt(cipherView, userId);
expect(cipher.key).toBeNull();
});
it("is defined when the cipher is viewPassword", async () => {
cipherView.viewPassword = true;
const { cipher } = await cipherService.encrypt(cipherView, userId);
expect(cipher.key).toBeDefined();
});
});
});
describe("encryptCipherForRotation", () => {
beforeEach(() => {
jest.spyOn<any, string>(cipherService, "encryptCipherWithCipherKey");
keyService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
});
it("is not called when feature flag is false", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.CipherKeyEncryption)
.mockResolvedValue(false);
await cipherService.encrypt(cipherView, userId);
expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
});
describe("when feature flag is true", () => {
beforeEach(() => {
configService.getFeatureFlag
.calledWith(FeatureFlag.CipherKeyEncryption)
.mockResolvedValue(true);
});
it("is called when cipher viewPassword is true", async () => {
cipherView.viewPassword = true;
await cipherService.encrypt(cipherView, userId);
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
});
it("is not called when cipher viewPassword is false and original cipher has no key", async () => {
cipherView.viewPassword = false;
await cipherService.encrypt(cipherView, userId, undefined, undefined, new Cipher());
expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled();
});
it("is called when cipher viewPassword is false and original cipher has a key", async () => {
cipherView.viewPassword = false;
await cipherService.encrypt(
cipherView,
userId,
undefined,
undefined,
encryptionContext.cipher,
);
expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled();
});
});
});
});
describe("getRotatedData", () => {
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
let decryptedCiphers: BehaviorSubject<Record<CipherId, CipherView>>;
let failedCiphers: BehaviorSubject<CipherView[]>;
let encryptedKey: EncString;
beforeEach(() => {
configService.getFeatureFlag
.calledWith(FeatureFlag.CipherKeyEncryption)
.mockResolvedValue(true);
configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true));
searchService.indexedEntityId$.mockReturnValue(of(null));
const keys = { userKey: originalUserKey } as CipherDecryptionKeys;
keyService.cipherDecryptionKeys$.mockReturnValue(of(keys));
const cipher1 = new CipherView(encryptionContext.cipher);
cipher1.id = "Cipher 1" as CipherId;
cipher1.organizationId = null;
const cipher2 = new CipherView(encryptionContext.cipher);
cipher2.id = "Cipher 2" as CipherId;
cipher2.organizationId = null;
decryptedCiphers = new BehaviorSubject({ [cipher1.id]: cipher1, [cipher2.id]: cipher2 });
jest
.spyOn(cipherService, "cipherViews$")
.mockImplementation((userId: UserId) =>
decryptedCiphers.pipe(map((ciphers) => Object.values(ciphers))),
);
failedCiphers = new BehaviorSubject<CipherView[]>([]);
jest
.spyOn(cipherService, "failedToDecryptCiphers$")
.mockImplementation((userId: UserId) => failedCiphers);
encryptService.unwrapSymmetricKey.mockResolvedValue(
new SymmetricCryptoKey(new Uint8Array(32)),
);
encryptedKey = new EncString("Re-encrypted Cipher Key");
encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey);
keyService.makeCipherKey.mockResolvedValue(
new SymmetricCryptoKey(new Uint8Array(32)) as CipherKey,
);
});
it("returns re-encrypted user ciphers", async () => {
const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId);
expect(result[0]).toMatchObject({ id: "Cipher 1", key: "Re-encrypted Cipher Key" });
expect(result[1]).toMatchObject({ id: "Cipher 2", key: "Re-encrypted Cipher Key" });
});
it("throws if the original user key is null", async () => {
await expect(cipherService.getRotatedData(null!, newUserKey, mockUserId)).rejects.toThrow(
"Original user key is required to rotate ciphers",
);
});
it("throws if the new user key is null", async () => {
await expect(
cipherService.getRotatedData(originalUserKey, null!, mockUserId),
).rejects.toThrow("New user key is required to rotate ciphers");
});
it("throws if the user has any failed to decrypt ciphers", async () => {
const badCipher = new CipherView(encryptionContext.cipher);
badCipher.id = "Cipher 3";
badCipher.organizationId = null;
badCipher.decryptionFailure = true;
failedCiphers.next([badCipher]);
await expect(
cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId),
).rejects.toThrow("Cannot rotate ciphers when decryption failures are present");
});
it("uses the sdk to re-encrypt ciphers when feature flag is enabled", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
.mockResolvedValue(true);
cipherEncryptionService.encryptCipherForRotation.mockResolvedValue({
cipher: encryptionContext.cipher,
encryptedFor: mockUserId,
});
const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId);
expect(result).toHaveLength(2);
expect(cipherEncryptionService.encryptCipherForRotation).toHaveBeenCalledWith(
expect.any(CipherView),
mockUserId,
newUserKey,
);
});
it("sends overlay update when cipherViews$ emits", async () => {
(cipherService.cipherViews$ as jest.Mock)?.mockRestore();
const decryptedView = new CipherView(encryptionContext.cipher);
jest.spyOn(cipherService, "getAllDecrypted").mockResolvedValue([decryptedView]);
const sendSpy = jest.spyOn(messageSender, "send");
await firstValueFrom(
cipherService
.cipherViews$(mockUserId)
.pipe(filter((cipherViews): cipherViews is CipherView[] => cipherViews != null)),
);
expect(sendSpy).toHaveBeenCalledWith("updateOverlayCiphers");
expect(sendSpy).toHaveBeenCalledTimes(1);
});
});
describe("decrypt", () => {
it("should call decrypt method of CipherEncryptionService when feature flag is true", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
.mockResolvedValue(true);
cipherEncryptionService.decrypt.mockResolvedValue(new CipherView(encryptionContext.cipher));
const result = await cipherService.decrypt(encryptionContext.cipher, userId);
expect(result).toEqual(new CipherView(encryptionContext.cipher));
expect(cipherEncryptionService.decrypt).toHaveBeenCalledWith(
encryptionContext.cipher,
userId,
);
});
it("should call legacy decrypt when feature flag is false", async () => {
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
configService.getFeatureFlag
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
.mockResolvedValue(false);
cipherService.getKeyForCipherKeyDecryption = jest.fn().mockResolvedValue(mockUserKey);
jest
.spyOn(encryptionContext.cipher, "decrypt")
.mockResolvedValue(new CipherView(encryptionContext.cipher));
const result = await cipherService.decrypt(encryptionContext.cipher, userId);
expect(result).toEqual(new CipherView(encryptionContext.cipher));
expect(encryptionContext.cipher.decrypt).toHaveBeenCalledWith(mockUserKey);
});
});
describe("getDecryptedAttachmentBuffer", () => {
const mockEncryptedContent = new Uint8Array([1, 2, 3]);
const mockDecryptedContent = new Uint8Array([4, 5, 6]);
it("should use SDK when feature flag is enabled", async () => {
const cipher = new Cipher(cipherData);
const attachment = new AttachmentView(cipher.attachments![0]);
configService.getFeatureFlag
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
.mockResolvedValue(true);
jest.spyOn(cipherService, "ciphers$").mockReturnValue(of({ [cipher.id]: cipherData }));
cipherEncryptionService.decryptAttachmentContent.mockResolvedValue(mockDecryptedContent);
const mockResponse = {
arrayBuffer: jest.fn().mockResolvedValue(mockEncryptedContent.buffer),
} as unknown as Response;
const result = await cipherService.getDecryptedAttachmentBuffer(
cipher.id as CipherId,
attachment,
mockResponse,
userId,
);
expect(result).toEqual(mockDecryptedContent);
expect(cipherEncryptionService.decryptAttachmentContent).toHaveBeenCalledWith(
cipher,
attachment,
mockEncryptedContent,
userId,
);
});
it("should use legacy decryption when feature flag is enabled", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
.mockResolvedValue(false);
const cipher = new Cipher(cipherData);
const attachment = new AttachmentView(cipher.attachments![0]);
attachment.key = makeSymmetricCryptoKey(64);
const mockResponse = {
arrayBuffer: jest.fn().mockResolvedValue(mockEncryptedContent.buffer),
} as unknown as Response;
const mockEncBuf = {} as EncArrayBuffer;
EncArrayBuffer.fromResponse = jest.fn().mockResolvedValue(mockEncBuf);
encryptService.decryptFileData.mockResolvedValue(mockDecryptedContent);
const result = await cipherService.getDecryptedAttachmentBuffer(
cipher.id as CipherId,
attachment,
mockResponse,
userId,
);
expect(result).toEqual(mockDecryptedContent);
expect(encryptService.decryptFileData).toHaveBeenCalledWith(mockEncBuf, attachment.key);
});
});
describe("shareWithServer()", () => {
it("should use cipherEncryptionService to move the cipher when feature flag enabled", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
.mockResolvedValue(true);
apiService.putShareCipher.mockResolvedValue(new CipherResponse(cipherData));
const expectedCipher = new Cipher(cipherData);
expectedCipher.organizationId = orgId;
const cipherView = new CipherView(expectedCipher);
const collectionIds = ["collection1", "collection2"] as CollectionId[];
cipherView.organizationId = undefined; // Ensure organizationId is undefined for this test
cipherEncryptionService.moveToOrganization.mockResolvedValue({
cipher: expectedCipher,
encryptedFor: userId,
});
await cipherService.shareWithServer(cipherView, orgId, collectionIds, userId);
// Expect SDK usage
expect(cipherEncryptionService.moveToOrganization).toHaveBeenCalledWith(
cipherView,
orgId,
userId,
);
// Expect collectionIds to be assigned
expect(apiService.putShareCipher).toHaveBeenCalledWith(
cipherView.id,
expect.objectContaining({
cipher: expect.objectContaining({ organizationId: orgId }),
collectionIds: collectionIds,
}),
);
});
it("should use legacy encryption when feature flag disabled", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM22136_SdkCipherEncryption)
.mockResolvedValue(false);
apiService.putShareCipher.mockResolvedValue(new CipherResponse(cipherData));
const expectedCipher = new Cipher(cipherData);
expectedCipher.organizationId = orgId;
const cipherView = new CipherView(expectedCipher);
const collectionIds = ["collection1", "collection2"] as CollectionId[];
cipherView.organizationId = undefined; // Ensure organizationId is undefined for this test
const oldEncryptSharedSpy = jest
.spyOn(cipherService as any, "encryptSharedCipher")
.mockResolvedValue({
cipher: expectedCipher,
encryptedFor: userId,
});
await cipherService.shareWithServer(cipherView, orgId, collectionIds, userId);
// Expect no SDK usage
expect(cipherEncryptionService.moveToOrganization).not.toHaveBeenCalled();
expect(oldEncryptSharedSpy).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: orgId,
collectionIds: collectionIds,
} as unknown as CipherView),
userId,
);
});
});
describe("decryptCiphers", () => {
let mockCiphers: Cipher[];
const cipher1_id = "11111111-1111-1111-1111-111111111111";
const cipher2_id = "22222222-2222-2222-2222-222222222222";
beforeEach(() => {
const originalUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const orgKey = new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey;
const keys = {
userKey: originalUserKey,
orgKeys: { [orgId]: orgKey },
} as CipherDecryptionKeys;
keyService.cipherDecryptionKeys$.mockReturnValue(of(keys));
mockCiphers = [
new Cipher({ ...cipherData, id: cipher1_id }),
new Cipher({ ...cipherData, id: cipher2_id }),
];
//// Mock the SDK response
cipherEncryptionService.decryptManyWithFailures.mockResolvedValue([
[{ id: mockCiphers[0].id, name: "Success 1" } as unknown as CipherListView],
[mockCiphers[1]], // Mock failed cipher
]);
});
it("should use the SDK for decryption when SDK feature flag is enabled", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
.mockResolvedValue(true);
// Set up expected results
const expectedSuccessCipherViews = [
{ id: mockCiphers[0].id, name: "Success 1" } as unknown as CipherListView,
];
const expectedFailedCipher = new CipherView(mockCiphers[1]);
expectedFailedCipher.name = "[error: cannot decrypt]";
expectedFailedCipher.decryptionFailure = true;
const expectedFailedCipherViews = [expectedFailedCipher];
// Execute
const [successes, failures] = await (cipherService as any).decryptCiphers(
mockCiphers,
userId,
);
// Verify the SDK was used for decryption
expect(cipherEncryptionService.decryptManyWithFailures).toHaveBeenCalledWith(
mockCiphers,
userId,
);
expect(successes).toEqual(expectedSuccessCipherViews);
expect(failures).toEqual(expectedFailedCipherViews);
});
it("should use legacy decryption when SDK feature flag is disabled", async () => {
configService.getFeatureFlag
.calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk)
.mockResolvedValue(false);
// Execute
const [successes, failures] = await (cipherService as any).decryptCiphers(
mockCiphers,
userId,
);
// Verify the SDK was not used for decryption
expect(cipherEncryptionService.decryptManyWithFailures).toHaveBeenCalledTimes(0);
expect(successes).toHaveLength(2);
expect(failures).toHaveLength(0);
});
});
describe("softDelete", () => {
it("clears archivedDate when soft deleting", async () => {
const cipherId = "cipher-id-1" as CipherId;
const archivedCipher = {
...cipherData,
id: cipherId,
archivedDate: "2024-01-01T12:00:00.000Z",
} as CipherData;
const ciphers = { [cipherId]: archivedCipher } as Record<CipherId, CipherData>;
stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).nextState(ciphers);
await cipherService.softDelete(cipherId, mockUserId);
const result = await firstValueFrom(
stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).state$,
);
expect(result[cipherId].archivedDate).toBeNull();
expect(result[cipherId].deletedDate).toBeDefined();
});
});
describe("replace (no upsert)", () => {
// In order to set up initial state we need to manually update the encrypted state
// which will result in an emission. All tests will have this baseline emission.
const TEST_BASELINE_EMISSIONS = 1;
const makeCipher = (id: string): CipherData =>
({
...cipherData,
id,
name: `Enc ${id}`,
}) as CipherData;
const tick = async () => new Promise((r) => setTimeout(r, 0));
const setEncryptedState = async (data: Record<CipherId, CipherData>, uid = userId) => {
// Directly set the encrypted state, this will result in a single emission
await stateProvider.getUser(uid, ENCRYPTED_CIPHERS).update(() => data);
// match services “next tick” behavior so subscribers see it
await tick();
};
it("emits and calls updateEncryptedCipherState when current state is empty and replace({}) is called", async () => {
// Ensure empty state
await setEncryptedState({});
const emissions: Array<Record<CipherId, CipherData>> = [];
const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v));
await tick();
const spy = jest.spyOn<any, any>(cipherService, "updateEncryptedCipherState");
// Calling replace with empty object MUST still update to trigger init emissions
await cipherService.replace({}, userId);
await tick();
expect(spy).toHaveBeenCalledTimes(1);
expect(emissions.length).toBeGreaterThanOrEqual(TEST_BASELINE_EMISSIONS + 1);
sub.unsubscribe();
});
it("does NOT emit or call updateEncryptedCipherState when state is non-empty and identical", async () => {
const A = makeCipher("A");
await setEncryptedState({ [A.id as CipherId]: A });
const emissions: Array<Record<CipherId, CipherData>> = [];
const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v));
await tick();
const spy = jest.spyOn<any, any>(cipherService, "updateEncryptedCipherState");
// identical snapshot → short-circuit path
await cipherService.replace({ [A.id as CipherId]: A }, userId);
await tick();
expect(spy).not.toHaveBeenCalled();
expect(emissions.length).toBe(TEST_BASELINE_EMISSIONS);
sub.unsubscribe();
});
it("emits and calls updateEncryptedCipherState when the provided state differs from current", async () => {
const A = makeCipher("A");
await setEncryptedState({ [A.id as CipherId]: A });
const emissions: Array<Record<CipherId, CipherData>> = [];
const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v));
await tick();
const spy = jest.spyOn<any, any>(cipherService, "updateEncryptedCipherState");
const B = makeCipher("B");
await cipherService.replace({ [B.id as CipherId]: B }, userId);
await tick();
expect(spy).toHaveBeenCalledTimes(1);
expect(emissions.length).toBeGreaterThanOrEqual(TEST_BASELINE_EMISSIONS + 1);
sub.unsubscribe();
});
});
describe("getCipherForUrl localData application", () => {
beforeEach(() => {
Object.defineProperty(autofillSettingsService, "autofillOnPageLoadDefault$", {
value: of(true),
writable: true,
});
});
it("should apply localData to ciphers when getCipherForUrl is called via getLastLaunchedForUrl", async () => {
const testUrl = "https://test-url.com";
const cipherId = "test-cipher-id" as CipherId;
const testLocalData = {
lastLaunched: Date.now().valueOf(),
lastUsedDate: Date.now().valueOf() - 1000,
};
jest.spyOn(cipherService, "localData$").mockReturnValue(of({ [cipherId]: testLocalData }));
const mockCipherView = new CipherView();
mockCipherView.id = cipherId;
mockCipherView.localData = null;
jest.spyOn(cipherService, "getAllDecryptedForUrl").mockResolvedValue([mockCipherView]);
const result = await cipherService.getLastLaunchedForUrl(testUrl, userId, true);
expect(result.localData).toEqual(testLocalData);
});
it("should apply localData to ciphers when getCipherForUrl is called via getLastUsedForUrl", async () => {
const testUrl = "https://test-url.com";
const cipherId = "test-cipher-id" as CipherId;
const testLocalData = { lastUsedDate: Date.now().valueOf() - 1000 };
jest.spyOn(cipherService, "localData$").mockReturnValue(of({ [cipherId]: testLocalData }));
const mockCipherView = new CipherView();
mockCipherView.id = cipherId;
mockCipherView.localData = null;
jest.spyOn(cipherService, "getAllDecryptedForUrl").mockResolvedValue([mockCipherView]);
const result = await cipherService.getLastUsedForUrl(testUrl, userId, true);
expect(result.localData).toEqual(testLocalData);
});
it("should not modify localData if it already matches in getCipherForUrl", async () => {
const testUrl = "https://test-url.com";
const cipherId = "test-cipher-id" as CipherId;
const existingLocalData = {
lastLaunched: Date.now().valueOf(),
lastUsedDate: Date.now().valueOf() - 1000,
};
jest
.spyOn(cipherService, "localData$")
.mockReturnValue(of({ [cipherId]: existingLocalData }));
const mockCipherView = new CipherView();
mockCipherView.id = cipherId;
mockCipherView.localData = existingLocalData;
jest.spyOn(cipherService, "getAllDecryptedForUrl").mockResolvedValue([mockCipherView]);
const result = await cipherService.getLastLaunchedForUrl(testUrl, userId, true);
expect(result.localData).toBe(existingLocalData);
});
});
});