import { MockProxy, mock } from "jest-mock-extended"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationBuilder } from "@bitwarden/common/state-migrations/migration-builder"; import { MigrationHelper } from "@bitwarden/common/state-migrations/migration-helper"; import { WebMigrationRunner } from "./web-migration-runner"; import { WindowStorageService } from "./window-storage.service"; describe("WebMigrationRunner", () => { let logService: MockProxy; let sessionStorageService: MockProxy; let localStorageService: MockProxy; let migrationBuilderService: MockProxy; let sut: WebMigrationRunner; beforeEach(() => { logService = mock(); sessionStorageService = mock(); localStorageService = mock(); migrationBuilderService = mock(); sut = new WebMigrationRunner( sessionStorageService, logService, migrationBuilderService, localStorageService, ); }); const mockMigrationBuilder = (migration: (helper: MigrationHelper) => Promise) => { migrationBuilderService.build.mockReturnValue({ migrate: async (helper: MigrationHelper) => { await migration(helper); }, with: () => { throw new Error("Don't use this in tests."); }, rollback: () => { throw new Error("Don't use this in tests."); }, } as unknown as MigrationBuilder); }; const mockGet = ( mockStorage: MockProxy, data: Record, ) => { mockStorage.get.mockImplementation((key) => { return Promise.resolve(data[key]); }); }; it("should run migration for both storage locations", async () => { mockGet(sessionStorageService, { stateVersion: 4, }); mockGet(localStorageService, {}); mockMigrationBuilder(async (helper) => { await helper.set("something", "something"); }); await sut.run(); expect(sessionStorageService.save).toHaveBeenCalledWith("something", "something"); expect(localStorageService.save).toHaveBeenCalledWith("something", "something"); }); it("should only migrate data in one migration if written defensively", async () => { mockGet(sessionStorageService, { stateVersion: 4, }); mockGet(localStorageService, { user1: { settings: { myData: "value", }, }, }); mockMigrationBuilder(async (helper) => { const account = await helper.get<{ settings?: { myData?: string } }>("user1"); const value = account?.settings?.myData; if (value) { await helper.setToUser("user1", { key: "key", stateDefinition: { name: "state" } }, value); } }); await sut.run(); expect(sessionStorageService.save).not.toHaveBeenCalled(); expect(localStorageService.save).toHaveBeenCalledWith("user_user1_state_key", "value"); }); it("should gather accounts differently", async () => { mockGet(sessionStorageService, { stateVersion: 10, authenticatedAccounts: ["sessionUser1", "sessionUser2"], sessionUser1: { data: 1, }, sessionUser2: { data: null, }, sessionUser3: { // User does NOT have authenticated accounts entry data: 3, }, }); const localStorageObject = { "8118af89-a621-4b0f-8dd2-4449569e5067": { data: 4, }, "cc202dba-55f8-4cbe-8c66-de37e48e7827": { data: null, }, otherThing: { data: 6, }, "badd2aff-a380-468f-855a-e476557055d5": null, "01f81ccd-fb18-460c-9a6b-811ef5300d4b": 3, }; mockGet(localStorageService, localStorageObject); localStorageService.getKeys.mockReturnValue(Object.keys(localStorageObject)); mockMigrationBuilder(async (helper) => { type ExpectedAccountType = { data?: number; }; async function migrateAccount(userId: string, account: ExpectedAccountType) { const value = account?.data; if (value != null) { await helper.setToUser(userId, { key: "key", stateDefinition: { name: "state" } }, value); delete account.data; await helper.set(userId, account); } } const accounts = await helper.getAccounts(); await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account))); }); await sut.run(); // Session storage has two users but only one with data expect(sessionStorageService.save).toHaveBeenCalledTimes(2); // Should move the data to the new location first expect(sessionStorageService.save).toHaveBeenNthCalledWith(1, "user_sessionUser1_state_key", 1); // Should then delete the migrated data and resave object expect(sessionStorageService.save).toHaveBeenNthCalledWith(2, "sessionUser1", {}); expect(sessionStorageService.get).toHaveBeenCalledTimes(4); // Should first get the state version so it knowns which migrations to run (not really used in this test) expect(sessionStorageService.get).toHaveBeenNthCalledWith(1, "stateVersion"); // "base" migration runner should trust the authenticatedAccounts stored value for knowing which accounts to migrate expect(sessionStorageService.get).toHaveBeenNthCalledWith(2, "authenticatedAccounts"); // Should get the data for each user expect(sessionStorageService.get).toHaveBeenNthCalledWith(3, "sessionUser1"); expect(sessionStorageService.get).toHaveBeenNthCalledWith(4, "sessionUser2"); expect(localStorageService.save).toHaveBeenCalledTimes(2); // Should migrate data for a user in local storage expect(localStorageService.save).toHaveBeenNthCalledWith( 1, "user_8118af89-a621-4b0f-8dd2-4449569e5067_state_key", 4, ); // Should update object with migrated data deleted expect(localStorageService.save).toHaveBeenNthCalledWith( 2, "8118af89-a621-4b0f-8dd2-4449569e5067", {}, ); expect(localStorageService.get).toHaveBeenCalledTimes(5); expect(localStorageService.get).toHaveBeenNthCalledWith(1, "stateVersion"); expect(localStorageService.get).toHaveBeenNthCalledWith( 2, "8118af89-a621-4b0f-8dd2-4449569e5067", ); expect(localStorageService.get).toHaveBeenNthCalledWith( 3, "cc202dba-55f8-4cbe-8c66-de37e48e7827", ); expect(localStorageService.get).toHaveBeenNthCalledWith( 4, "badd2aff-a380-468f-855a-e476557055d5", ); expect(localStorageService.get).toHaveBeenNthCalledWith( 5, "01f81ccd-fb18-460c-9a6b-811ef5300d4b", ); }); it("should default currentVersion to 12 if no stateVersion exists", async () => { mockGet(sessionStorageService, { stateVersion: 14, }); mockGet(localStorageService, {}); let runCount = 0; mockMigrationBuilder(async (helper) => { if (runCount === 0) { // This should be the session storage run expect(helper.currentVersion).toBe(14); } else if (runCount === 1) { // This should be the local storage run, and it should be the default version expect(helper.currentVersion).toBe(12); } else { throw new Error("Should not have been called more than twice"); } runCount++; }); await sut.run(); }); it("should respect local storage stateVersion", async () => { mockGet(sessionStorageService, { stateVersion: 14, }); mockGet(localStorageService, { stateVersion: 18, }); let runCount = 0; mockMigrationBuilder(async (helper) => { if (runCount === 0) { // This should be the session storage run expect(helper.currentVersion).toBe(14); } else if (runCount === 1) { // This should be the local storage run, and it should be the default version expect(helper.currentVersion).toBe(18); } else { throw new Error("Should not have been called more than twice"); } runCount++; }); await sut.run(); }); });