import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { Utils } from "@bitwarden/common/misc/utils"; import { EncString } from "@bitwarden/common/models/domain/encString"; import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; import { EncryptService } from "@bitwarden/common/src/services/encrypt.service"; import BrowserLocalStorageService from "./browserLocalStorage.service"; import BrowserMemoryStorageService from "./browserMemoryStorage.service"; import { KeyGenerationService } from "./keyGeneration.service"; import { LocalBackedSessionStorageService } from "./localBackedSessionStorage.service"; describe("Browser Session Storage Service", () => { let encryptService: SubstituteOf; let keyGenerationService: SubstituteOf; let cache: Map; const testObj = { a: 1, b: 2 }; let localStorage: BrowserLocalStorageService; let sessionStorage: BrowserMemoryStorageService; const key = new SymmetricCryptoKey( Utils.fromUtf8ToArray("00000000000000000000000000000000").buffer ); let getSessionKeySpy: jest.SpyInstance; const mockEnc = (input: string) => Promise.resolve(new EncString("ENCRYPTED" + input)); let sut: LocalBackedSessionStorageService; beforeEach(() => { encryptService = Substitute.for(); keyGenerationService = Substitute.for(); sut = new LocalBackedSessionStorageService(encryptService, keyGenerationService); cache = sut["cache"]; localStorage = sut["localStorage"]; sessionStorage = sut["sessionStorage"]; getSessionKeySpy = jest.spyOn(sut, "getSessionEncKey"); getSessionKeySpy.mockResolvedValue(key); }); it("should exist", () => { expect(sut).toBeInstanceOf(LocalBackedSessionStorageService); }); describe("get", () => { it("should return from cache", async () => { cache.set("test", testObj); const result = await sut.get("test"); expect(result).toStrictEqual(testObj); }); describe("not in cache", () => { const session = { test: testObj }; beforeEach(() => { jest.spyOn(sut, "getSessionEncKey").mockResolvedValue(key); }); describe("no session retrieved", () => { let result: any; let spy: jest.SpyInstance; beforeEach(async () => { spy = jest.spyOn(sut, "getLocalSession").mockResolvedValue(null); result = await sut.get("test"); }); it("should grab from session if not in cache", async () => { expect(spy).toHaveBeenCalledWith(key); }); it("should return null if session is null", async () => { expect(result).toBeNull(); }); }); describe("session retrieved from storage", () => { beforeEach(() => { jest.spyOn(sut, "getLocalSession").mockResolvedValue(session); }); it("should return null if session does not have the key", async () => { const result = await sut.get("DNE"); expect(result).toBeNull(); }); it("should return the value retrieved from session", async () => { const result = await sut.get("test"); expect(result).toEqual(session.test); }); it("should set retrieved values in cache", async () => { await sut.get("test"); expect(cache.has("test")).toBe(true); expect(cache.get("test")).toEqual(session.test); }); }); }); }); describe("has", () => { it("should be false if `get` returns null", async () => { const spy = jest.spyOn(sut, "get"); spy.mockResolvedValue(null); expect(await sut.has("test")).toBe(false); expect(spy).toHaveBeenCalledWith("test"); }); it("should be true if `get` returns non-null", async () => { const spy = jest.spyOn(sut, "get"); spy.mockResolvedValue({}); expect(await sut.has("test")).toBe(true); expect(spy).toHaveBeenCalledWith("test"); }); }); describe("remove", () => { it("should save null", async () => { const spy = jest.spyOn(sut, "save"); spy.mockResolvedValue(null); await sut.remove("test"); expect(spy).toHaveBeenCalledWith("test", null); }); }); describe("save", () => { describe("caching", () => { beforeEach(() => { jest.spyOn(localStorage, "get").mockResolvedValue(null); jest.spyOn(sessionStorage, "get").mockResolvedValue(null); jest.spyOn(localStorage, "save").mockResolvedValue(); jest.spyOn(sessionStorage, "save").mockResolvedValue(); }); it("should remove key from cache if value is null", async () => { cache.set("test", {}); const deleteSpy = jest.spyOn(cache, "delete"); expect(cache.has("test")).toBe(true); await sut.save("test", null); expect(cache.has("test")).toBe(false); expect(deleteSpy).toHaveBeenCalledWith("test"); }); it("should set cache if value is non-null", async () => { expect(cache.has("test")).toBe(false); const setSpy = jest.spyOn(cache, "set"); await sut.save("test", testObj); expect(cache.get("test")).toBe(testObj); expect(setSpy).toHaveBeenCalledWith("test", testObj); }); }); describe("local storing", () => { let setSpy: jest.SpyInstance; beforeEach(() => { setSpy = jest.spyOn(sut, "setLocalSession").mockResolvedValue(); }); it("should store a new session", async () => { jest.spyOn(sut, "getLocalSession").mockResolvedValue(null); await sut.save("test", testObj); expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key); }); it("should update an existing session", async () => { const existingObj = { test: testObj }; jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj); await sut.save("test2", testObj); expect(setSpy).toHaveBeenCalledWith({ test2: testObj, ...existingObj }, key); }); it("should overwrite an existing item in session", async () => { const existingObj = { test: {} }; jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj); await sut.save("test", testObj); expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key); }); }); }); describe("getSessionKey", () => { beforeEach(() => { getSessionKeySpy.mockRestore(); }); it("should return the stored symmetric crypto key", async () => { jest.spyOn(sessionStorage, "get").mockResolvedValue({ ...key }); const result = await sut.getSessionEncKey(); expect(result).toStrictEqual(key); }); describe("new key creation", () => { beforeEach(() => { jest.spyOn(sessionStorage, "get").mockResolvedValue(null); keyGenerationService.makeEphemeralKey().resolves(key); jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); }); it("should create a symmetric crypto key", async () => { const result = await sut.getSessionEncKey(); expect(result).toStrictEqual(key); keyGenerationService.received(1).makeEphemeralKey(); }); it("should store a symmetric crypto key if it makes one", async () => { const spy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); await sut.getSessionEncKey(); expect(spy).toBeCalledWith(key); }); }); }); describe("getLocalSession", () => { it("should return null if session is null", async () => { const spy = jest.spyOn(localStorage, "get").mockResolvedValue(null); const result = await sut.getLocalSession(key); expect(result).toBeNull(); expect(spy).toBeCalledWith("session"); }); describe("non-null sessions", () => { const session = { test: "test" }; const encSession = new EncString(JSON.stringify(session)); const decryptedSession = JSON.stringify(session); beforeEach(() => { jest.spyOn(localStorage, "get").mockResolvedValue(encSession.encryptedString); }); it("should decrypt returned sessions", async () => { encryptService.decryptToUtf8(encSession, key).resolves(decryptedSession); await sut.getLocalSession(key); encryptService.received(1).decryptToUtf8(encSession, key); }); it("should parse session", async () => { encryptService.decryptToUtf8(encSession, key).resolves(decryptedSession); const result = await sut.getLocalSession(key); expect(result).toEqual(session); }); it("should remove state if decryption fails", async () => { encryptService.decryptToUtf8(Arg.any(), Arg.any()).resolves(null); const setSessionEncKeySpy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); const removeLocalSessionSpy = jest.spyOn(localStorage, "remove").mockResolvedValue(); const result = await sut.getLocalSession(key); expect(result).toBeNull(); expect(setSessionEncKeySpy).toHaveBeenCalledWith(null); expect(removeLocalSessionSpy).toHaveBeenCalledWith("session"); }); }); }); describe("setLocalSession", () => { const testSession = { test: "a" }; const testJSON = JSON.stringify(testSession); it("should encrypt a stringified session", async () => { encryptService.encrypt(Arg.any(), Arg.any()).mimicks(mockEnc); jest.spyOn(localStorage, "save").mockResolvedValue(); await sut.setLocalSession(testSession, key); encryptService.received(1).encrypt(testJSON, key); }); it("should remove local session if null", async () => { encryptService.encrypt(Arg.any(), Arg.any()).resolves(null); const spy = jest.spyOn(localStorage, "remove").mockResolvedValue(); await sut.setLocalSession(null, key); expect(spy).toHaveBeenCalledWith("session"); }); it("should save encrypted string", async () => { encryptService.encrypt(Arg.any(), Arg.any()).mimicks(mockEnc); const spy = jest.spyOn(localStorage, "save").mockResolvedValue(); await sut.setLocalSession(testSession, key); expect(spy).toHaveBeenCalledWith("session", (await mockEnc(testJSON)).encryptedString); }); }); describe("setSessionKey", () => { it("should remove if null", async () => { const spy = jest.spyOn(sessionStorage, "remove").mockResolvedValue(); await sut.setSessionEncKey(null); expect(spy).toHaveBeenCalledWith("localEncryptionKey"); }); it("should save key when not null", async () => { const spy = jest.spyOn(sessionStorage, "save").mockResolvedValue(); await sut.setSessionEncKey(key); expect(spy).toHaveBeenCalledWith("localEncryptionKey", key); }); }); });