From 3340af8084514ffb1edc300a2dae8d7af95798b1 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 30 Aug 2023 12:57:20 -0500 Subject: [PATCH] PM-3585 Improve state migrations (#5009) * WIP: safer state migrations Co-authored-by: Justin Baur * Add min version check and remove old migrations Co-authored-by: Oscar Hinton * Add rollback and version checking * Add state version move migration * Expand tests and improve typing for Migrations * Remove StateMigration Service * Rewrite version 5 and 6 migrations * Add all but initial migration to supported migrations * Handle stateVersion location in migrator update versions * Move to unique migrations directory * Disallow imports outside of state-migrations * Lint and test fixes * Do not run migrations if we cannot determine state * Fix desktop background StateService build * Document Migration builder class * Add debug logging to migrations * Comment on migrator overrides * Use specific property names * `npm run prettier` :robot: * Insert new migration * Set stateVersion when creating new globals object * PR comments * Fix migrate imports * Move migration building into `migrate` function * Export current version from migration definitions * Move file version concerns to migrator * Update migrate spec to reflect new version requirements * Fix import paths * Prefer unique state data * Remove unnecessary async * Prefer to not use `any` --------- Co-authored-by: Justin Baur Co-authored-by: Oscar Hinton --- .../browser/cipher-context-menu-handler.ts | 3 - .../browser/context-menu-clicked-handler.ts | 3 - .../browser/main-context-menu-handler.ts | 3 - .../browser/src/background/main.background.ts | 8 - .../state-migration-service.factory.ts | 40 -- .../state-service.factory.ts | 8 +- .../platform/listeners/on-command-listener.ts | 6 - .../platform/listeners/on-install-listener.ts | 3 - .../src/platform/listeners/update-badge.ts | 3 - .../services/browser-state.service.spec.ts | 4 - .../services/browser-state.service.ts | 3 - .../src/popup/services/services.module.ts | 18 +- apps/cli/src/bw.ts | 9 - .../src/app/services/services.module.ts | 2 - apps/desktop/src/main.ts | 1 - apps/web/src/app/core/core.module.ts | 7 - .../src/app/core/state-migration.service.ts | 13 - apps/web/src/app/core/state/state.service.ts | 3 - .../src/services/jslib-services.module.ts | 8 - libs/common/src/enums/index.ts | 1 - libs/common/src/enums/state-version.enum.ts | 10 - .../abstractions/state-migration.service.ts | 4 - .../platform/abstractions/state.service.ts | 2 - .../platform/models/domain/global-state.ts | 3 +- .../services/state-migration.service.spec.ts | 216 ------- .../services/state-migration.service.ts | 587 ------------------ .../src/platform/services/state.service.ts | 24 +- .../src/state-migrations/.eslintrc.json | 24 + libs/common/src/state-migrations/index.ts | 1 + .../src/state-migrations/migrate.spec.ts | 67 ++ libs/common/src/state-migrations/migrate.ts | 60 ++ .../migration-builder.spec.ts | 117 ++++ .../src/state-migrations/migration-builder.ts | 106 ++++ .../state-migrations/migration-helper.spec.ts | 84 +++ .../src/state-migrations/migration-helper.ts | 37 ++ .../migrations/3-fix-premium.spec.ts | 111 ++++ .../migrations/3-fix-premium.ts | 48 ++ .../4-remove-ever-been-unlocked.spec.ts | 75 +++ .../migrations/4-remove-ever-been-unlocked.ts | 32 + .../5-add-key-type-to-org-keys.spec.ts | 141 +++++ .../migrations/5-add-key-type-to-org-keys.ts | 67 ++ .../6-remove-legacy-etm-key.spec.ts | 80 +++ .../migrations/6-remove-legacy-etm-key.ts | 32 + ...e-biometric-auto-prompt-to-account.spec.ts | 102 +++ ...7-move-biometric-auto-prompt-to-account.ts | 45 ++ .../migrations/8-move-state-version.spec.ts | 90 +++ .../migrations/8-move-state-version.ts | 37 ++ .../migrations/min-version.spec.ts | 29 + .../migrations/min-version.ts | 26 + .../src/state-migrations/migrator.spec.ts | 75 +++ libs/common/src/state-migrations/migrator.ts | 40 ++ 51 files changed, 1538 insertions(+), 980 deletions(-) delete mode 100644 apps/browser/src/platform/background/service-factories/state-migration-service.factory.ts delete mode 100644 apps/web/src/app/core/state-migration.service.ts delete mode 100644 libs/common/src/enums/state-version.enum.ts delete mode 100644 libs/common/src/platform/abstractions/state-migration.service.ts delete mode 100644 libs/common/src/platform/services/state-migration.service.spec.ts delete mode 100644 libs/common/src/platform/services/state-migration.service.ts create mode 100644 libs/common/src/state-migrations/.eslintrc.json create mode 100644 libs/common/src/state-migrations/index.ts create mode 100644 libs/common/src/state-migrations/migrate.spec.ts create mode 100644 libs/common/src/state-migrations/migrate.ts create mode 100644 libs/common/src/state-migrations/migration-builder.spec.ts create mode 100644 libs/common/src/state-migrations/migration-builder.ts create mode 100644 libs/common/src/state-migrations/migration-helper.spec.ts create mode 100644 libs/common/src/state-migrations/migration-helper.ts create mode 100644 libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/3-fix-premium.ts create mode 100644 libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts create mode 100644 libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts create mode 100644 libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts create mode 100644 libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts create mode 100644 libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/8-move-state-version.ts create mode 100644 libs/common/src/state-migrations/migrations/min-version.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/min-version.ts create mode 100644 libs/common/src/state-migrations/migrator.spec.ts create mode 100644 libs/common/src/state-migrations/migrator.ts diff --git a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts index fe6479aae51..6140db260f5 100644 --- a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts @@ -66,9 +66,6 @@ export class CipherContextMenuHandler { clipboardWriteCallback: NOT_IMPLEMENTED, win: self, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index 9a14ea06da0..a6bff50a195 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -88,9 +88,6 @@ export class ContextMenuClickedHandler { clipboardWriteCallback: NOT_IMPLEMENTED, win: self, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index 9b16aa266db..b9af3dd191f 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -79,9 +79,6 @@ export class MainContextMenuHandler { logServiceOptions: { isDev: false, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index c65a8697631..31e81c198f6 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -59,7 +59,6 @@ import { EncryptServiceImplementation } from "@bitwarden/common/platform/service import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; -import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; import { SystemService } from "@bitwarden/common/platform/services/system.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; @@ -177,7 +176,6 @@ export default class MainBackground { searchService: SearchServiceAbstraction; notificationsService: NotificationsServiceAbstraction; stateService: StateServiceAbstraction; - stateMigrationService: StateMigrationService; systemService: SystemServiceAbstraction; eventCollectionService: EventCollectionServiceAbstraction; eventUploadService: EventUploadServiceAbstraction; @@ -262,17 +260,11 @@ export default class MainBackground { new KeyGenerationService(this.cryptoFunctionService) ) : new MemoryStorageService(); - this.stateMigrationService = new StateMigrationService( - this.storageService, - this.secureStorageService, - new StateFactory(GlobalState, Account) - ); this.stateService = new BrowserStateService( this.storageService, this.secureStorageService, this.memoryStorageService, this.logService, - this.stateMigrationService, new StateFactory(GlobalState, Account) ); this.platformUtilsService = new BrowserPlatformUtilsService( diff --git a/apps/browser/src/platform/background/service-factories/state-migration-service.factory.ts b/apps/browser/src/platform/background/service-factories/state-migration-service.factory.ts deleted file mode 100644 index 8d4ee969583..00000000000 --- a/apps/browser/src/platform/background/service-factories/state-migration-service.factory.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; -import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; -import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; - -import { Account } from "../../../models/account"; - -import { CachedServices, factory, FactoryOptions } from "./factory-options"; -import { - diskStorageServiceFactory, - DiskStorageServiceInitOptions, - secureStorageServiceFactory, - SecureStorageServiceInitOptions, -} from "./storage-service.factory"; - -type StateMigrationServiceFactoryOptions = FactoryOptions & { - stateMigrationServiceOptions: { - stateFactory: StateFactory; - }; -}; - -export type StateMigrationServiceInitOptions = StateMigrationServiceFactoryOptions & - DiskStorageServiceInitOptions & - SecureStorageServiceInitOptions; - -export function stateMigrationServiceFactory( - cache: { stateMigrationService?: StateMigrationService } & CachedServices, - opts: StateMigrationServiceInitOptions -): Promise { - return factory( - cache, - "stateMigrationService", - opts, - async () => - new StateMigrationService( - await diskStorageServiceFactory(cache, opts), - await secureStorageServiceFactory(cache, opts), - opts.stateMigrationServiceOptions.stateFactory - ) - ); -} diff --git a/apps/browser/src/platform/background/service-factories/state-service.factory.ts b/apps/browser/src/platform/background/service-factories/state-service.factory.ts index f926d428890..7d3aaf9b6f3 100644 --- a/apps/browser/src/platform/background/service-factories/state-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/state-service.factory.ts @@ -6,10 +6,6 @@ import { BrowserStateService } from "../../services/browser-state.service"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; -import { - stateMigrationServiceFactory, - StateMigrationServiceInitOptions, -} from "./state-migration-service.factory"; import { diskStorageServiceFactory, secureStorageServiceFactory, @@ -30,8 +26,7 @@ export type StateServiceInitOptions = StateServiceFactoryOptions & DiskStorageServiceInitOptions & SecureStorageServiceInitOptions & MemoryStorageServiceInitOptions & - LogServiceInitOptions & - StateMigrationServiceInitOptions; + LogServiceInitOptions; export async function stateServiceFactory( cache: { stateService?: BrowserStateService } & CachedServices, @@ -47,7 +42,6 @@ export async function stateServiceFactory( await secureStorageServiceFactory(cache, opts), await memoryStorageServiceFactory(cache, opts), await logServiceFactory(cache, opts), - await stateMigrationServiceFactory(cache, opts), opts.stateServiceOptions.stateFactory, opts.stateServiceOptions.useAccountCache ) diff --git a/apps/browser/src/platform/listeners/on-command-listener.ts b/apps/browser/src/platform/listeners/on-command-listener.ts index 65af31e173c..0e2cf03828d 100644 --- a/apps/browser/src/platform/listeners/on-command-listener.ts +++ b/apps/browser/src/platform/listeners/on-command-listener.ts @@ -47,9 +47,6 @@ const doAutoFillLogin = async (tab: chrome.tabs.Tab): Promise => { stateServiceOptions: { stateFactory: new StateFactory(GlobalState, Account), }, - stateMigrationServiceOptions: { - stateFactory: new StateFactory(GlobalState, Account), - }, apiServiceOptions: { logoutCallback: () => Promise.resolve(), }, @@ -94,9 +91,6 @@ const doGeneratePasswordToClipboard = async (tab: chrome.tabs.Tab): Promise Promise.resolve(), win: self, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, diff --git a/apps/browser/src/platform/listeners/on-install-listener.ts b/apps/browser/src/platform/listeners/on-install-listener.ts index 480e811fd26..0394941e283 100644 --- a/apps/browser/src/platform/listeners/on-install-listener.ts +++ b/apps/browser/src/platform/listeners/on-install-listener.ts @@ -23,9 +23,6 @@ export async function onInstallListener(details: chrome.runtime.InstalledDetails stateServiceOptions: { stateFactory: new StateFactory(GlobalState, Account), }, - stateMigrationServiceOptions: { - stateFactory: new StateFactory(GlobalState, Account), - }, }; const environmentService = await environmentServiceFactory(cache, opts); diff --git a/apps/browser/src/platform/listeners/update-badge.ts b/apps/browser/src/platform/listeners/update-badge.ts index 89b620ad6fe..1b692eb9b97 100644 --- a/apps/browser/src/platform/listeners/update-badge.ts +++ b/apps/browser/src/platform/listeners/update-badge.ts @@ -272,9 +272,6 @@ export class UpdateBadge { stateServiceOptions: { stateFactory: new StateFactory(GlobalState, Account), }, - stateMigrationServiceOptions: { - stateFactory: new StateFactory(GlobalState, Account), - }, apiServiceOptions: { logoutCallback: () => Promise.reject("not implemented"), }, diff --git a/apps/browser/src/platform/services/browser-state.service.spec.ts b/apps/browser/src/platform/services/browser-state.service.spec.ts index d6bb83f7fb5..0712416172c 100644 --- a/apps/browser/src/platform/services/browser-state.service.spec.ts +++ b/apps/browser/src/platform/services/browser-state.service.spec.ts @@ -8,7 +8,6 @@ import { import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { State } from "@bitwarden/common/platform/models/domain/state"; -import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; @@ -26,7 +25,6 @@ describe("Browser State Service", () => { let secureStorageService: MockProxy; let diskStorageService: MockProxy; let logService: MockProxy; - let stateMigrationService: MockProxy; let stateFactory: MockProxy>; let useAccountCache: boolean; @@ -39,7 +37,6 @@ describe("Browser State Service", () => { secureStorageService = mock(); diskStorageService = mock(); logService = mock(); - stateMigrationService = mock(); stateFactory = mock(); // turn off account cache for tests useAccountCache = false; @@ -64,7 +61,6 @@ describe("Browser State Service", () => { secureStorageService, memoryStorageService, logService, - stateMigrationService, stateFactory, useAccountCache ); diff --git a/apps/browser/src/platform/services/browser-state.service.ts b/apps/browser/src/platform/services/browser-state.service.ts index 34fa1a1d0f3..5e356e7fbe8 100644 --- a/apps/browser/src/platform/services/browser-state.service.ts +++ b/apps/browser/src/platform/services/browser-state.service.ts @@ -1,7 +1,6 @@ import { BehaviorSubject } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateMigrationService } from "@bitwarden/common/platform/abstractions/state-migration.service"; import { AbstractStorageService, AbstractMemoryStorageService, @@ -41,7 +40,6 @@ export class BrowserStateService secureStorageService: AbstractStorageService, memoryStorageService: AbstractMemoryStorageService, logService: LogService, - stateMigrationService: StateMigrationService, stateFactory: StateFactory, useAccountCache = true ) { @@ -50,7 +48,6 @@ export class BrowserStateService secureStorageService, memoryStorageService, logService, - stateMigrationService, stateFactory, useAccountCache ); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 191e2c78060..261f6abe37d 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -47,7 +47,6 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateMigrationService } from "@bitwarden/common/platform/abstractions/state-migration.service"; import { StateService as BaseStateServiceAbstraction, StateService, @@ -442,36 +441,23 @@ function getBgService(service: keyof MainBackground) { provide: MEMORY_STORAGE, useFactory: getBgService("memoryStorageService"), }, - { - provide: StateMigrationService, - useFactory: getBgService("stateMigrationService"), - deps: [], - }, { provide: StateServiceAbstraction, useFactory: ( storageService: AbstractStorageService, secureStorageService: AbstractStorageService, memoryStorageService: AbstractMemoryStorageService, - logService: LogServiceAbstraction, - stateMigrationService: StateMigrationService + logService: LogServiceAbstraction ) => { return new BrowserStateService( storageService, secureStorageService, memoryStorageService, logService, - stateMigrationService, new StateFactory(GlobalState, Account) ); }, - deps: [ - AbstractStorageService, - SECURE_STORAGE, - MEMORY_STORAGE, - LogServiceAbstraction, - StateMigrationService, - ], + deps: [AbstractStorageService, SECURE_STORAGE, MEMORY_STORAGE, LogServiceAbstraction], }, { provide: UsernameGenerationServiceAbstraction, diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 1bcaa1a2acf..42ba158ee72 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -37,7 +37,6 @@ import { EnvironmentService } from "@bitwarden/common/platform/services/environm import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service"; -import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; import { StateService } from "@bitwarden/common/platform/services/state.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { OrganizationUserServiceImplementation } from "@bitwarden/common/services/organization-user/organization-user.service.implementation"; @@ -136,7 +135,6 @@ export class Main { keyConnectorService: KeyConnectorService; userVerificationService: UserVerificationService; stateService: StateService; - stateMigrationService: StateMigrationService; organizationService: OrganizationService; providerService: ProviderService; twoFactorService: TwoFactorService; @@ -188,18 +186,11 @@ export class Main { this.memoryStorageService = new MemoryStorageService(); - this.stateMigrationService = new StateMigrationService( - this.storageService, - this.secureStorageService, - new StateFactory(GlobalState, Account) - ); - this.stateService = new StateService( this.storageService, this.secureStorageService, this.memoryStorageService, this.logService, - this.stateMigrationService, new StateFactory(GlobalState, Account) ); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index ded0366dc16..42208077c33 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -28,7 +28,6 @@ import { } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/platform/abstractions/state-migration.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service"; @@ -134,7 +133,6 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); SECURE_STORAGE, MEMORY_STORAGE, LogService, - StateMigrationServiceAbstraction, STATE_FACTORY, STATE_SERVICE_USE_CACHE, ], diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9f15d0d24d9..5107d31b1c5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -90,7 +90,6 @@ export class Main { null, this.memoryStorageService, this.logService, - null, new StateFactory(GlobalState, Account), false // Do not use disk caching because this will get out of sync with the renderer service ); diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 03f20ad2955..b2e44d7e3db 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -17,7 +17,6 @@ import { FileDownloadService } from "@bitwarden/common/platform/abstractions/fil import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/platform/abstractions/state-migration.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; @@ -27,7 +26,6 @@ import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@ import { PolicyListService } from "../admin-console/core/policy-list.service"; import { HtmlStorageService } from "../core/html-storage.service"; import { I18nService } from "../core/i18n.service"; -import { StateMigrationService } from "../core/state-migration.service"; import { CollectionAdminService } from "../vault/core/collection-admin.service"; import { PasswordRepromptService } from "../vault/core/password-reprompt.service"; @@ -84,11 +82,6 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service"; }, { provide: MessagingServiceAbstraction, useClass: BroadcasterMessagingService }, { provide: ModalServiceAbstraction, useClass: ModalService }, - { - provide: StateMigrationServiceAbstraction, - useClass: StateMigrationService, - deps: [AbstractStorageService, SECURE_STORAGE, STATE_FACTORY], - }, StateService, { provide: BaseStateServiceAbstraction, diff --git a/apps/web/src/app/core/state-migration.service.ts b/apps/web/src/app/core/state-migration.service.ts deleted file mode 100644 index c1d6e2ded5d..00000000000 --- a/apps/web/src/app/core/state-migration.service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { StateMigrationService as BaseStateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; - -import { Account } from "./state/account"; -import { GlobalState } from "./state/global-state"; - -export class StateMigrationService extends BaseStateMigrationService { - protected async migrationStateFrom1To2(): Promise { - await super.migrateStateFrom1To2(); - const globals = (await this.get("global")) ?? this.stateFactory.createGlobal(null); - globals.rememberEmail = (await this.get("rememberEmail")) ?? globals.rememberEmail; - await this.set("global", globals); - } -} diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts index 60f09ceae36..c95077bfbcc 100644 --- a/apps/web/src/app/core/state/state.service.ts +++ b/apps/web/src/app/core/state/state.service.ts @@ -7,7 +7,6 @@ import { STATE_SERVICE_USE_CACHE, } from "@bitwarden/angular/services/injection-tokens"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateMigrationService } from "@bitwarden/common/platform/abstractions/state-migration.service"; import { AbstractMemoryStorageService, AbstractStorageService, @@ -30,7 +29,6 @@ export class StateService extends BaseStateService { @Inject(SECURE_STORAGE) secureStorageService: AbstractStorageService, @Inject(MEMORY_STORAGE) memoryStorageService: AbstractMemoryStorageService, logService: LogService, - stateMigrationService: StateMigrationService, @Inject(STATE_FACTORY) stateFactory: StateFactory, @Inject(STATE_SERVICE_USE_CACHE) useAccountCache = true ) { @@ -39,7 +37,6 @@ export class StateService extends BaseStateService { secureStorageService, memoryStorageService, logService, - stateMigrationService, stateFactory, useAccountCache ); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 14b26ca43da..df64c25c914 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -77,7 +77,6 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/platform/abstractions/state-migration.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -94,7 +93,6 @@ import { EncryptServiceImplementation } from "@bitwarden/common/platform/service import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation"; import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; -import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; import { StateService } from "@bitwarden/common/platform/services/state.service"; import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; @@ -480,16 +478,10 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; SECURE_STORAGE, MEMORY_STORAGE, LogService, - StateMigrationServiceAbstraction, STATE_FACTORY, STATE_SERVICE_USE_CACHE, ], }, - { - provide: StateMigrationServiceAbstraction, - useClass: StateMigrationService, - deps: [AbstractStorageService, SECURE_STORAGE, STATE_FACTORY], - }, { provide: VaultExportServiceAbstraction, useClass: VaultExportService, diff --git a/libs/common/src/enums/index.ts b/libs/common/src/enums/index.ts index 87a688b856e..b62b3ecfa81 100644 --- a/libs/common/src/enums/index.ts +++ b/libs/common/src/enums/index.ts @@ -18,7 +18,6 @@ export * from "./notification-type.enum"; export * from "./product-type.enum"; export * from "./provider-type.enum"; export * from "./secure-note-type.enum"; -export * from "./state-version.enum"; export * from "./storage-location.enum"; export * from "./theme-type.enum"; export * from "./uri-match-type.enum"; diff --git a/libs/common/src/enums/state-version.enum.ts b/libs/common/src/enums/state-version.enum.ts deleted file mode 100644 index 927ce3a1105..00000000000 --- a/libs/common/src/enums/state-version.enum.ts +++ /dev/null @@ -1,10 +0,0 @@ -export enum StateVersion { - One = 1, // Original flat key/value pair store - Two = 2, // Move to a typed State object - Three = 3, // Fix migration of users' premium status - Four = 4, // Fix 'Never Lock' option by removing stale data - Five = 5, // Migrate to new storage of encrypted organization keys - Six = 6, // Delete account.keys.legacyEtmKey property - Seven = 7, // Remove global desktop auto prompt setting, move to account - Latest = Seven, -} diff --git a/libs/common/src/platform/abstractions/state-migration.service.ts b/libs/common/src/platform/abstractions/state-migration.service.ts deleted file mode 100644 index f16777a159f..00000000000 --- a/libs/common/src/platform/abstractions/state-migration.service.ts +++ /dev/null @@ -1,4 +0,0 @@ -export abstract class StateMigrationService { - needsMigration: () => Promise; - migrate: () => Promise; -} diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 4a2b515b74a..82813718de3 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -495,8 +495,6 @@ export abstract class StateService { setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise; getApproveLoginRequests: (options?: StorageOptions) => Promise; setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise; - getStateVersion: () => Promise; - setStateVersion: (value: number) => Promise; getWindow: () => Promise; setWindow: (value: WindowState) => Promise; /** diff --git a/libs/common/src/platform/models/domain/global-state.ts b/libs/common/src/platform/models/domain/global-state.ts index dfe3c6c417f..30ad32124cf 100644 --- a/libs/common/src/platform/models/domain/global-state.ts +++ b/libs/common/src/platform/models/domain/global-state.ts @@ -1,5 +1,5 @@ import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls"; -import { StateVersion, ThemeType } from "../../../enums"; +import { ThemeType } from "../../../enums"; import { WindowState } from "../../../models/domain/window-state"; export class GlobalState { @@ -25,7 +25,6 @@ export class GlobalState { enableBiometrics?: boolean; biometricText?: string; noAutoPromptBiometricsText?: string; - stateVersion: StateVersion = StateVersion.One; environmentUrls: EnvironmentUrls = new EnvironmentUrls(); enableTray?: boolean; enableMinimizeToTray?: boolean; diff --git a/libs/common/src/platform/services/state-migration.service.spec.ts b/libs/common/src/platform/services/state-migration.service.spec.ts deleted file mode 100644 index 7bbd19106d5..00000000000 --- a/libs/common/src/platform/services/state-migration.service.spec.ts +++ /dev/null @@ -1,216 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; -import { MockProxy, any, mock } from "jest-mock-extended"; - -import { StateVersion } from "../../enums"; -import { AbstractStorageService } from "../abstractions/storage.service"; -import { StateFactory } from "../factories/state-factory"; -import { Account } from "../models/domain/account"; -import { GlobalState } from "../models/domain/global-state"; - -import { StateMigrationService } from "./state-migration.service"; - -const userId = "USER_ID"; - -// Note: each test calls the private migration method for that migration, -// so that we don't accidentally run all following migrations as well - -describe("State Migration Service", () => { - let storageService: MockProxy; - let secureStorageService: SubstituteOf; - let stateFactory: SubstituteOf; - - let stateMigrationService: StateMigrationService; - - beforeEach(() => { - storageService = mock(); - secureStorageService = Substitute.for(); - stateFactory = Substitute.for(); - - stateMigrationService = new StateMigrationService( - storageService, - secureStorageService, - stateFactory - ); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("StateVersion 3 to 4 migration", () => { - beforeEach(() => { - const globalVersion3: Partial = { - stateVersion: StateVersion.Three, - }; - - storageService.get.calledWith("global", any()).mockResolvedValue(globalVersion3); - storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]); - }); - - it("clears everBeenUnlocked", async () => { - const accountVersion3: Account = { - profile: { - apiKeyClientId: null, - convertAccountToKeyConnector: null, - email: "EMAIL", - emailVerified: true, - everBeenUnlocked: true, - hasPremiumPersonally: false, - kdfIterations: 100000, - kdfType: 0, - keyHash: "KEY_HASH", - lastSync: "LAST_SYNC", - userId: userId, - usesKeyConnector: false, - forcePasswordResetReason: null, - }, - }; - - const expectedAccountVersion4: Account = { - profile: { - ...accountVersion3.profile, - }, - }; - delete expectedAccountVersion4.profile.everBeenUnlocked; - - storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion3); - - await (stateMigrationService as any).migrateStateFrom3To4(); - - expect(storageService.save).toHaveBeenCalledTimes(2); - expect(storageService.save).toHaveBeenCalledWith(userId, expectedAccountVersion4, any()); - }); - - it("updates StateVersion number", async () => { - await (stateMigrationService as any).migrateStateFrom3To4(); - - expect(storageService.save).toHaveBeenCalledWith( - "global", - { stateVersion: StateVersion.Four }, - any() - ); - expect(storageService.save).toHaveBeenCalledTimes(1); - }); - }); - - describe("StateVersion 4 to 5 migration", () => { - it("migrates organization keys to new format", async () => { - const accountVersion4 = new Account({ - keys: { - organizationKeys: { - encrypted: { - orgOneId: "orgOneEncKey", - orgTwoId: "orgTwoEncKey", - orgThreeId: "orgThreeEncKey", - }, - }, - }, - } as any); - - const expectedAccount = new Account({ - keys: { - organizationKeys: { - encrypted: { - orgOneId: { - type: "organization", - key: "orgOneEncKey", - }, - orgTwoId: { - type: "organization", - key: "orgTwoEncKey", - }, - orgThreeId: { - type: "organization", - key: "orgThreeEncKey", - }, - }, - } as any, - } as any, - }); - - const migratedAccount = await (stateMigrationService as any).migrateAccountFrom4To5( - accountVersion4 - ); - - expect(migratedAccount).toEqual(expectedAccount); - }); - }); - - describe("StateVersion 5 to 6 migration", () => { - it("deletes account.keys.legacyEtmKey value", async () => { - const accountVersion5 = new Account({ - keys: { - legacyEtmKey: "legacy key", - }, - } as any); - - const migratedAccount = await (stateMigrationService as any).migrateAccountFrom5To6( - accountVersion5 - ); - - expect(migratedAccount.keys.legacyEtmKey).toBeUndefined(); - }); - }); - - describe("StateVersion 6 to 7 migration", () => { - it("should delete global.noAutoPromptBiometrics value", async () => { - storageService.get - .calledWith("global", any()) - .mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true }); - storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([]); - - await stateMigrationService.migrate(); - - expect(storageService.save).toHaveBeenCalledWith( - "global", - { - stateVersion: StateVersion.Seven, - }, - any() - ); - }); - - it("should call migrateStateFrom6To7 on each account", async () => { - const accountVersion6 = new Account({ - otherStuff: "other stuff", - } as any); - - storageService.get - .calledWith("global", any()) - .mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true }); - storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]); - storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion6); - - const migrateSpy = jest.fn(); - (stateMigrationService as any).migrateAccountFrom6To7 = migrateSpy; - - await stateMigrationService.migrate(); - - expect(migrateSpy).toHaveBeenCalledWith(true, accountVersion6); - }); - - it("should update account.settings.disableAutoBiometricsPrompt value if global is no prompt", async () => { - const result = await (stateMigrationService as any).migrateAccountFrom6To7(true, { - otherStuff: "other stuff", - }); - - expect(result).toEqual({ - otherStuff: "other stuff", - settings: { - disableAutoBiometricsPrompt: true, - }, - }); - }); - - it("should not update account.settings.disableAutoBiometricsPrompt value if global auto prompt is enabled", async () => { - const result = await (stateMigrationService as any).migrateAccountFrom6To7(false, { - otherStuff: "other stuff", - }); - - expect(result).toEqual({ - otherStuff: "other stuff", - }); - }); - }); -}); diff --git a/libs/common/src/platform/services/state-migration.service.ts b/libs/common/src/platform/services/state-migration.service.ts deleted file mode 100644 index 234d1b2bff8..00000000000 --- a/libs/common/src/platform/services/state-migration.service.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { OrganizationData } from "../../admin-console/models/data/organization.data"; -import { PolicyData } from "../../admin-console/models/data/policy.data"; -import { ProviderData } from "../../admin-console/models/data/provider.data"; -import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; -import { TokenService } from "../../auth/services/token.service"; -import { StateVersion, ThemeType, KdfType, HtmlStorageLocation } from "../../enums"; -import { EventData } from "../../models/data/event.data"; -import { GeneratedPasswordHistory } from "../../tools/generator/password"; -import { SendData } from "../../tools/send/models/data/send.data"; -import { CipherData } from "../../vault/models/data/cipher.data"; -import { CollectionData } from "../../vault/models/data/collection.data"; -import { FolderData } from "../../vault/models/data/folder.data"; -import { AbstractStorageService } from "../abstractions/storage.service"; -import { StateFactory } from "../factories/state-factory"; -import { - Account, - AccountSettings, - EncryptionPair, - AccountSettingsSettings, -} from "../models/domain/account"; -import { EncString } from "../models/domain/enc-string"; -import { GlobalState } from "../models/domain/global-state"; -import { StorageOptions } from "../models/domain/storage-options"; - -// Originally (before January 2022) storage was handled as a flat key/value pair store. -// With the move to a typed object for state storage these keys should no longer be in use anywhere outside of this migration. -const v1Keys: { [key: string]: string } = { - accessToken: "accessToken", - alwaysShowDock: "alwaysShowDock", - autoConfirmFingerprints: "autoConfirmFingerprints", - autoFillOnPageLoadDefault: "autoFillOnPageLoadDefault", - biometricAwaitingAcceptance: "biometricAwaitingAcceptance", - biometricFingerprintValidated: "biometricFingerprintValidated", - biometricText: "biometricText", - biometricUnlock: "biometric", - clearClipboard: "clearClipboardKey", - clientId: "apikey_clientId", - clientSecret: "apikey_clientSecret", - collapsedGroupings: "collapsedGroupings", - convertAccountToKeyConnector: "convertAccountToKeyConnector", - defaultUriMatch: "defaultUriMatch", - disableAddLoginNotification: "disableAddLoginNotification", - disableAutoBiometricsPrompt: "noAutoPromptBiometrics", - disableAutoTotpCopy: "disableAutoTotpCopy", - disableBadgeCounter: "disableBadgeCounter", - disableChangedPasswordNotification: "disableChangedPasswordNotification", - disableContextMenuItem: "disableContextMenuItem", - disableFavicon: "disableFavicon", - disableGa: "disableGa", - dontShowCardsCurrentTab: "dontShowCardsCurrentTab", - dontShowIdentitiesCurrentTab: "dontShowIdentitiesCurrentTab", - emailVerified: "emailVerified", - enableAlwaysOnTop: "enableAlwaysOnTopKey", - enableAutoFillOnPageLoad: "enableAutoFillOnPageLoad", - enableBiometric: "enabledBiometric", - enableBrowserIntegration: "enableBrowserIntegration", - enableBrowserIntegrationFingerprint: "enableBrowserIntegrationFingerprint", - enableCloseToTray: "enableCloseToTray", - enableFullWidth: "enableFullWidth", - enableMinimizeToTray: "enableMinimizeToTray", - enableStartToTray: "enableStartToTrayKey", - enableTray: "enableTray", - encKey: "encKey", // Generated Symmetric Key - encOrgKeys: "encOrgKeys", - encPrivate: "encPrivateKey", - encProviderKeys: "encProviderKeys", - entityId: "entityId", - entityType: "entityType", - environmentUrls: "environmentUrls", - equivalentDomains: "equivalentDomains", - eventCollection: "eventCollection", - forcePasswordReset: "forcePasswordReset", - history: "generatedPasswordHistory", - installedVersion: "installedVersion", - kdf: "kdf", - kdfIterations: "kdfIterations", - key: "key", // Master Key - keyHash: "keyHash", - lastActive: "lastActive", - localData: "sitesLocalData", - locale: "locale", - mainWindowSize: "mainWindowSize", - minimizeOnCopyToClipboard: "minimizeOnCopyToClipboardKey", - neverDomains: "neverDomains", - noAutoPromptBiometricsText: "noAutoPromptBiometricsText", - openAtLogin: "openAtLogin", - passwordGenerationOptions: "passwordGenerationOptions", - pinProtected: "pinProtectedKey", - protectedPin: "protectedPin", - refreshToken: "refreshToken", - ssoCodeVerifier: "ssoCodeVerifier", - ssoIdentifier: "ssoOrgIdentifier", - ssoState: "ssoState", - stamp: "securityStamp", - theme: "theme", - userEmail: "userEmail", - userId: "userId", - usesConnector: "usesKeyConnector", - vaultTimeoutAction: "vaultTimeoutAction", - vaultTimeout: "lockOption", - rememberedEmail: "rememberedEmail", -}; - -const v1KeyPrefixes: { [key: string]: string } = { - ciphers: "ciphers_", - collections: "collections_", - folders: "folders_", - lastSync: "lastSync_", - policies: "policies_", - twoFactorToken: "twoFactorToken_", - organizations: "organizations_", - providers: "providers_", - sends: "sends_", - settings: "settings_", -}; - -const keys = { - global: "global", - authenticatedAccounts: "authenticatedAccounts", - activeUserId: "activeUserId", - tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication - accountActivity: "accountActivity", -}; - -const partialKeys = { - autoKey: "_masterkey_auto", - biometricKey: "_masterkey_biometric", - masterKey: "_masterkey", -}; - -export class StateMigrationService< - TGlobalState extends GlobalState = GlobalState, - TAccount extends Account = Account -> { - constructor( - protected storageService: AbstractStorageService, - protected secureStorageService: AbstractStorageService, - protected stateFactory: StateFactory - ) {} - - async needsMigration(): Promise { - const currentStateVersion = await this.getCurrentStateVersion(); - return currentStateVersion == null || currentStateVersion < StateVersion.Latest; - } - - async migrate(): Promise { - let currentStateVersion = await this.getCurrentStateVersion(); - while (currentStateVersion < StateVersion.Latest) { - switch (currentStateVersion) { - case StateVersion.One: - await this.migrateStateFrom1To2(); - break; - case StateVersion.Two: - await this.migrateStateFrom2To3(); - break; - case StateVersion.Three: - await this.migrateStateFrom3To4(); - break; - case StateVersion.Four: { - const authenticatedAccounts = await this.getAuthenticatedAccounts(); - for (const account of authenticatedAccounts) { - const migratedAccount = await this.migrateAccountFrom4To5(account); - await this.set(account.profile.userId, migratedAccount); - } - await this.setCurrentStateVersion(StateVersion.Five); - break; - } - case StateVersion.Five: { - const authenticatedAccounts = await this.getAuthenticatedAccounts(); - for (const account of authenticatedAccounts) { - const migratedAccount = await this.migrateAccountFrom5To6(account); - await this.set(account.profile.userId, migratedAccount); - } - await this.setCurrentStateVersion(StateVersion.Six); - break; - } - case StateVersion.Six: { - const authenticatedAccounts = await this.getAuthenticatedAccounts(); - const globals = (await this.getGlobals()) as any; - for (const account of authenticatedAccounts) { - const migratedAccount = await this.migrateAccountFrom6To7( - globals?.noAutoPromptBiometrics, - account - ); - await this.set(account.profile.userId, migratedAccount); - } - if (globals) { - delete globals.noAutoPromptBiometrics; - } - await this.set(keys.global, globals); - await this.setCurrentStateVersion(StateVersion.Seven); - } - } - - currentStateVersion += 1; - } - } - - protected async migrateStateFrom1To2(): Promise { - const clearV1Keys = async (clearingUserId?: string) => { - for (const key in v1Keys) { - if (key == null) { - continue; - } - await this.set(v1Keys[key], null); - } - if (clearingUserId != null) { - for (const keyPrefix in v1KeyPrefixes) { - if (keyPrefix == null) { - continue; - } - await this.set(v1KeyPrefixes[keyPrefix] + userId, null); - } - } - }; - - // Some processes, like biometrics, may have already defined a value before migrations are run. - // We don't want to null out those values if they don't exist in the old storage scheme (like for new installs) - // So, the OOO for migration is that we: - // 1. Check for an existing storage value from the old storage structure OR - // 2. Check for a value already set by processes that run before migration OR - // 3. Assign the default value - const globals: any = - (await this.get(keys.global)) ?? this.stateFactory.createGlobal(null); - globals.stateVersion = StateVersion.Two; - globals.environmentUrls = - (await this.get(v1Keys.environmentUrls)) ?? globals.environmentUrls; - globals.locale = (await this.get(v1Keys.locale)) ?? globals.locale; - globals.noAutoPromptBiometrics = - (await this.get(v1Keys.disableAutoBiometricsPrompt)) ?? - globals.noAutoPromptBiometrics; - globals.noAutoPromptBiometricsText = - (await this.get(v1Keys.noAutoPromptBiometricsText)) ?? - globals.noAutoPromptBiometricsText; - globals.ssoCodeVerifier = - (await this.get(v1Keys.ssoCodeVerifier)) ?? globals.ssoCodeVerifier; - globals.ssoOrganizationIdentifier = - (await this.get(v1Keys.ssoIdentifier)) ?? globals.ssoOrganizationIdentifier; - globals.ssoState = (await this.get(v1Keys.ssoState)) ?? globals.ssoState; - globals.rememberedEmail = - (await this.get(v1Keys.rememberedEmail)) ?? globals.rememberedEmail; - globals.theme = (await this.get(v1Keys.theme)) ?? globals.theme; - globals.vaultTimeout = (await this.get(v1Keys.vaultTimeout)) ?? globals.vaultTimeout; - globals.vaultTimeoutAction = - (await this.get(v1Keys.vaultTimeoutAction)) ?? globals.vaultTimeoutAction; - globals.window = (await this.get(v1Keys.mainWindowSize)) ?? globals.window; - globals.enableTray = (await this.get(v1Keys.enableTray)) ?? globals.enableTray; - globals.enableMinimizeToTray = - (await this.get(v1Keys.enableMinimizeToTray)) ?? globals.enableMinimizeToTray; - globals.enableCloseToTray = - (await this.get(v1Keys.enableCloseToTray)) ?? globals.enableCloseToTray; - globals.enableStartToTray = - (await this.get(v1Keys.enableStartToTray)) ?? globals.enableStartToTray; - globals.openAtLogin = (await this.get(v1Keys.openAtLogin)) ?? globals.openAtLogin; - globals.alwaysShowDock = - (await this.get(v1Keys.alwaysShowDock)) ?? globals.alwaysShowDock; - globals.enableBrowserIntegration = - (await this.get(v1Keys.enableBrowserIntegration)) ?? - globals.enableBrowserIntegration; - globals.enableBrowserIntegrationFingerprint = - (await this.get(v1Keys.enableBrowserIntegrationFingerprint)) ?? - globals.enableBrowserIntegrationFingerprint; - - const userId = - (await this.get(v1Keys.userId)) ?? (await this.get(v1Keys.entityId)); - - const defaultAccount = this.stateFactory.createAccount(null); - const accountSettings: AccountSettings = { - autoConfirmFingerPrints: - (await this.get(v1Keys.autoConfirmFingerprints)) ?? - defaultAccount.settings.autoConfirmFingerPrints, - autoFillOnPageLoadDefault: - (await this.get(v1Keys.autoFillOnPageLoadDefault)) ?? - defaultAccount.settings.autoFillOnPageLoadDefault, - biometricUnlock: - (await this.get(v1Keys.biometricUnlock)) ?? - defaultAccount.settings.biometricUnlock, - clearClipboard: - (await this.get(v1Keys.clearClipboard)) ?? defaultAccount.settings.clearClipboard, - defaultUriMatch: - (await this.get(v1Keys.defaultUriMatch)) ?? defaultAccount.settings.defaultUriMatch, - disableAddLoginNotification: - (await this.get(v1Keys.disableAddLoginNotification)) ?? - defaultAccount.settings.disableAddLoginNotification, - disableAutoBiometricsPrompt: - (await this.get(v1Keys.disableAutoBiometricsPrompt)) ?? - defaultAccount.settings.disableAutoBiometricsPrompt, - disableAutoTotpCopy: - (await this.get(v1Keys.disableAutoTotpCopy)) ?? - defaultAccount.settings.disableAutoTotpCopy, - disableBadgeCounter: - (await this.get(v1Keys.disableBadgeCounter)) ?? - defaultAccount.settings.disableBadgeCounter, - disableChangedPasswordNotification: - (await this.get(v1Keys.disableChangedPasswordNotification)) ?? - defaultAccount.settings.disableChangedPasswordNotification, - disableContextMenuItem: - (await this.get(v1Keys.disableContextMenuItem)) ?? - defaultAccount.settings.disableContextMenuItem, - disableGa: (await this.get(v1Keys.disableGa)) ?? defaultAccount.settings.disableGa, - dontShowCardsCurrentTab: - (await this.get(v1Keys.dontShowCardsCurrentTab)) ?? - defaultAccount.settings.dontShowCardsCurrentTab, - dontShowIdentitiesCurrentTab: - (await this.get(v1Keys.dontShowIdentitiesCurrentTab)) ?? - defaultAccount.settings.dontShowIdentitiesCurrentTab, - enableAlwaysOnTop: - (await this.get(v1Keys.enableAlwaysOnTop)) ?? - defaultAccount.settings.enableAlwaysOnTop, - enableAutoFillOnPageLoad: - (await this.get(v1Keys.enableAutoFillOnPageLoad)) ?? - defaultAccount.settings.enableAutoFillOnPageLoad, - enableBiometric: - (await this.get(v1Keys.enableBiometric)) ?? - defaultAccount.settings.enableBiometric, - enableFullWidth: - (await this.get(v1Keys.enableFullWidth)) ?? - defaultAccount.settings.enableFullWidth, - environmentUrls: globals.environmentUrls ?? defaultAccount.settings.environmentUrls, - equivalentDomains: - (await this.get(v1Keys.equivalentDomains)) ?? - defaultAccount.settings.equivalentDomains, - minimizeOnCopyToClipboard: - (await this.get(v1Keys.minimizeOnCopyToClipboard)) ?? - defaultAccount.settings.minimizeOnCopyToClipboard, - neverDomains: - (await this.get(v1Keys.neverDomains)) ?? defaultAccount.settings.neverDomains, - passwordGenerationOptions: - (await this.get(v1Keys.passwordGenerationOptions)) ?? - defaultAccount.settings.passwordGenerationOptions, - pinProtected: Object.assign(new EncryptionPair(), { - decrypted: null, - encrypted: await this.get(v1Keys.pinProtected), - }), - protectedPin: await this.get(v1Keys.protectedPin), - settings: - userId == null - ? null - : await this.get(v1KeyPrefixes.settings + userId), - vaultTimeout: - (await this.get(v1Keys.vaultTimeout)) ?? defaultAccount.settings.vaultTimeout, - vaultTimeoutAction: - (await this.get(v1Keys.vaultTimeoutAction)) ?? - defaultAccount.settings.vaultTimeoutAction, - }; - - // (userId == null) = no logged in user (so no known userId) and we need to temporarily store account specific settings in state to migrate on first auth - // (userId != null) = we have a currently authed user (so known userId) with encrypted data and other key settings we can move, no need to temporarily store account settings - if (userId == null) { - await this.set(keys.tempAccountSettings, accountSettings); - await this.set(keys.global, globals); - await this.set(keys.authenticatedAccounts, []); - await this.set(keys.activeUserId, null); - await clearV1Keys(); - return; - } - - globals.twoFactorToken = await this.get(v1KeyPrefixes.twoFactorToken + userId); - await this.set(keys.global, globals); - await this.set(userId, { - data: { - addEditCipherInfo: null, - ciphers: { - decrypted: null, - encrypted: await this.get<{ [id: string]: CipherData }>(v1KeyPrefixes.ciphers + userId), - }, - collapsedGroupings: null, - collections: { - decrypted: null, - encrypted: await this.get<{ [id: string]: CollectionData }>( - v1KeyPrefixes.collections + userId - ), - }, - eventCollection: await this.get(v1Keys.eventCollection), - folders: { - decrypted: null, - encrypted: await this.get<{ [id: string]: FolderData }>(v1KeyPrefixes.folders + userId), - }, - localData: null, - organizations: await this.get<{ [id: string]: OrganizationData }>( - v1KeyPrefixes.organizations + userId - ), - passwordGenerationHistory: { - decrypted: null, - encrypted: await this.get(v1Keys.history), - }, - policies: { - decrypted: null, - encrypted: await this.get<{ [id: string]: PolicyData }>(v1KeyPrefixes.policies + userId), - }, - providers: await this.get<{ [id: string]: ProviderData }>(v1KeyPrefixes.providers + userId), - sends: { - decrypted: null, - encrypted: await this.get<{ [id: string]: SendData }>(v1KeyPrefixes.sends + userId), - }, - }, - keys: { - apiKeyClientSecret: await this.get(v1Keys.clientSecret), - cryptoMasterKey: null, - cryptoMasterKeyAuto: null, - cryptoMasterKeyB64: null, - cryptoMasterKeyBiometric: null, - cryptoSymmetricKey: { - encrypted: await this.get(v1Keys.encKey), - decrypted: null, - }, - legacyEtmKey: null, - organizationKeys: { - decrypted: null, - encrypted: await this.get(v1Keys.encOrgKeys), - }, - privateKey: { - decrypted: null, - encrypted: await this.get(v1Keys.encPrivate), - }, - providerKeys: { - decrypted: null, - encrypted: await this.get(v1Keys.encProviderKeys), - }, - publicKey: null, - }, - profile: { - apiKeyClientId: await this.get(v1Keys.clientId), - authenticationStatus: null, - convertAccountToKeyConnector: await this.get(v1Keys.convertAccountToKeyConnector), - email: await this.get(v1Keys.userEmail), - emailVerified: await this.get(v1Keys.emailVerified), - entityId: null, - entityType: null, - everBeenUnlocked: null, - forcePasswordReset: null, - hasPremiumPersonally: null, - kdfIterations: await this.get(v1Keys.kdfIterations), - kdfType: await this.get(v1Keys.kdf), - keyHash: await this.get(v1Keys.keyHash), - lastSync: null, - userId: userId, - usesKeyConnector: null, - }, - settings: accountSettings, - tokens: { - accessToken: await this.get(v1Keys.accessToken), - decodedToken: null, - refreshToken: await this.get(v1Keys.refreshToken), - securityStamp: null, - }, - }); - - await this.set(keys.authenticatedAccounts, [userId]); - await this.set(keys.activeUserId, userId); - - const accountActivity: { [userId: string]: number } = { - [userId]: await this.get(v1Keys.lastActive), - }; - accountActivity[userId] = await this.get(v1Keys.lastActive); - await this.set(keys.accountActivity, accountActivity); - - await clearV1Keys(userId); - - if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "biometric" })) { - await this.secureStorageService.save( - `${userId}${partialKeys.biometricKey}`, - await this.secureStorageService.get(v1Keys.key, { keySuffix: "biometric" }), - { keySuffix: "biometric" } - ); - await this.secureStorageService.remove(v1Keys.key, { keySuffix: "biometric" }); - } - - if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "auto" })) { - await this.secureStorageService.save( - `${userId}${partialKeys.autoKey}`, - await this.secureStorageService.get(v1Keys.key, { keySuffix: "auto" }), - { keySuffix: "auto" } - ); - await this.secureStorageService.remove(v1Keys.key, { keySuffix: "auto" }); - } - - if (await this.secureStorageService.has(v1Keys.key)) { - await this.secureStorageService.save( - `${userId}${partialKeys.masterKey}`, - await this.secureStorageService.get(v1Keys.key) - ); - await this.secureStorageService.remove(v1Keys.key); - } - } - - protected async migrateStateFrom2To3(): Promise { - const authenticatedUserIds = await this.get(keys.authenticatedAccounts); - await Promise.all( - authenticatedUserIds.map(async (userId) => { - const account = await this.get(userId); - if ( - account?.profile?.hasPremiumPersonally === null && - account.tokens?.accessToken != null - ) { - const decodedToken = await TokenService.decodeToken(account.tokens.accessToken); - account.profile.hasPremiumPersonally = decodedToken.premium; - await this.set(userId, account); - } - }) - ); - - const globals = await this.getGlobals(); - globals.stateVersion = StateVersion.Three; - await this.set(keys.global, globals); - } - - protected async migrateStateFrom3To4(): Promise { - const authenticatedUserIds = await this.get(keys.authenticatedAccounts); - await Promise.all( - authenticatedUserIds.map(async (userId) => { - const account = await this.get(userId); - if (account?.profile?.everBeenUnlocked != null) { - delete account.profile.everBeenUnlocked; - return this.set(userId, account); - } - }) - ); - - const globals = await this.getGlobals(); - globals.stateVersion = StateVersion.Four; - await this.set(keys.global, globals); - } - - protected async migrateAccountFrom4To5(account: TAccount): Promise { - const encryptedOrgKeys = account.keys?.organizationKeys?.encrypted; - if (encryptedOrgKeys != null) { - for (const [orgId, encKey] of Object.entries(encryptedOrgKeys)) { - encryptedOrgKeys[orgId] = { - type: "organization", - key: encKey as unknown as string, // Account v4 does not reflect the current account model so we have to cast - }; - } - } - - return account; - } - - protected async migrateAccountFrom5To6(account: TAccount): Promise { - delete (account as any).keys?.legacyEtmKey; - return account; - } - - protected async migrateAccountFrom6To7( - globalSetting: boolean, - account: TAccount - ): Promise { - if (globalSetting) { - account.settings = Object.assign({}, account.settings, { disableAutoBiometricsPrompt: true }); - } - return account; - } - - protected get options(): StorageOptions { - return { htmlStorageLocation: HtmlStorageLocation.Local }; - } - - protected get(key: string): Promise { - return this.storageService.get(key, this.options); - } - - protected set(key: string, value: any): Promise { - if (value == null) { - return this.storageService.remove(key, this.options); - } - return this.storageService.save(key, value, this.options); - } - - protected async getGlobals(): Promise { - return await this.get(keys.global); - } - - protected async getCurrentStateVersion(): Promise { - return (await this.getGlobals())?.stateVersion ?? StateVersion.One; - } - - protected async setCurrentStateVersion(newVersion: StateVersion): Promise { - const globals = await this.getGlobals(); - globals.stateVersion = newVersion; - await this.set(keys.global, globals); - } - - protected async getAuthenticatedAccounts(): Promise { - const authenticatedUserIds = await this.get(keys.authenticatedAccounts); - return Promise.all(authenticatedUserIds.map((id) => this.get(id))); - } -} diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 14ba4abfd39..5fdf40e8458 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -21,6 +21,7 @@ import { import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { EventData } from "../../models/data/event.data"; import { WindowState } from "../../models/domain/window-state"; +import { migrate } from "../../state-migrations"; import { GeneratedPasswordHistory } from "../../tools/generator/password"; import { SendData } from "../../tools/send/models/data/send.data"; import { SendView } from "../../tools/send/models/view/send.view"; @@ -32,7 +33,6 @@ import { CipherView } from "../../vault/models/view/cipher.view"; import { CollectionView } from "../../vault/models/view/collection.view"; import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info"; import { LogService } from "../abstractions/log.service"; -import { StateMigrationService } from "../abstractions/state-migration.service"; import { StateService as StateServiceAbstraction } from "../abstractions/state.service"; import { AbstractMemoryStorageService, @@ -61,6 +61,7 @@ import { const keys = { state: "state", + stateVersion: "stateVersion", global: "global", authenticatedAccounts: "authenticatedAccounts", activeUserId: "activeUserId", @@ -106,7 +107,6 @@ export class StateService< protected secureStorageService: AbstractStorageService, protected memoryStorageService: AbstractMemoryStorageService, protected logService: LogService, - protected stateMigrationService: StateMigrationService, protected stateFactory: StateFactory, protected useAccountCache: boolean = true ) { @@ -133,9 +133,7 @@ export class StateService< return; } - if (await this.stateMigrationService.needsMigration()) { - await this.stateMigrationService.migrate(); - } + await migrate(this.storageService, this.logService); await this.state().then(async (state) => { if (state == null) { @@ -2724,16 +2722,6 @@ export class StateService< ); } - async getStateVersion(): Promise { - return (await this.getGlobals(await this.defaultOnDiskLocalOptions())).stateVersion ?? 1; - } - - async setStateVersion(value: number): Promise { - const globals = await this.getGlobals(await this.defaultOnDiskOptions()); - globals.stateVersion = value; - await this.saveGlobals(globals, await this.defaultOnDiskOptions()); - } - async getWindow(): Promise { const globals = await this.getGlobals(await this.defaultOnDiskOptions()); return globals?.window != null && Object.keys(globals.window).length > 0 @@ -2838,7 +2826,11 @@ export class StateService< globals = await this.getGlobalsFromDisk(options); } - return globals ?? this.createGlobals(); + if (globals == null) { + globals = this.createGlobals(); + } + + return globals; } protected async saveGlobals(globals: TGlobalState, options: StorageOptions) { diff --git a/libs/common/src/state-migrations/.eslintrc.json b/libs/common/src/state-migrations/.eslintrc.json new file mode 100644 index 00000000000..4b66f0a32fa --- /dev/null +++ b/libs/common/src/state-migrations/.eslintrc.json @@ -0,0 +1,24 @@ +{ + "overrides": [ + { + "files": ["*"], + "rules": { + "import/no-restricted-paths": [ + "error", + { + "basePath": "libs/common/src/state-migrations", + "zones": [ + { + "target": "./", + "from": "../", + // Relative to from, not basePath + "except": ["state-migrations"], + "message": "State migrations should rarely import from the greater codebase. If you need to import from another location, take into account the likelihood of change in that code and consider copying to the migration instead." + } + ] + } + ] + } + } + ] +} diff --git a/libs/common/src/state-migrations/index.ts b/libs/common/src/state-migrations/index.ts new file mode 100644 index 00000000000..c883b1ca811 --- /dev/null +++ b/libs/common/src/state-migrations/index.ts @@ -0,0 +1 @@ +export { migrate, CURRENT_VERSION } from "./migrate"; diff --git a/libs/common/src/state-migrations/migrate.spec.ts b/libs/common/src/state-migrations/migrate.spec.ts new file mode 100644 index 00000000000..ade3d261f69 --- /dev/null +++ b/libs/common/src/state-migrations/migrate.spec.ts @@ -0,0 +1,67 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages +import { LogService } from "../platform/abstractions/log.service"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations +import { AbstractStorageService } from "../platform/abstractions/storage.service"; + +import { CURRENT_VERSION, currentVersion, migrate } from "./migrate"; +import { MigrationBuilder } from "./migration-builder"; + +jest.mock("./migration-builder", () => { + return { + MigrationBuilder: { + create: jest.fn().mockReturnThis(), + }, + }; +}); + +describe("migrate", () => { + it("should not run migrations if state is empty", async () => { + const storage = mock(); + const logService = mock(); + storage.get.mockReturnValueOnce(null); + await migrate(storage, logService); + expect(MigrationBuilder.create).not.toHaveBeenCalled(); + }); + + it("should set to current version if state is empty", async () => { + const storage = mock(); + const logService = mock(); + storage.get.mockReturnValueOnce(null); + await migrate(storage, logService); + expect(storage.save).toHaveBeenCalledWith("stateVersion", CURRENT_VERSION); + }); +}); + +describe("currentVersion", () => { + let storage: MockProxy; + let logService: MockProxy; + + beforeEach(() => { + storage = mock(); + logService = mock(); + }); + + it("should return -1 if no version", async () => { + storage.get.mockReturnValueOnce(null); + expect(await currentVersion(storage, logService)).toEqual(-1); + }); + + it("should return version", async () => { + storage.get.calledWith("stateVersion").mockReturnValueOnce(1 as any); + expect(await currentVersion(storage, logService)).toEqual(1); + }); + + it("should return version from global", async () => { + storage.get.calledWith("stateVersion").mockReturnValueOnce(null); + storage.get.calledWith("global").mockReturnValueOnce({ stateVersion: 1 } as any); + expect(await currentVersion(storage, logService)).toEqual(1); + }); + + it("should prefer root version to global", async () => { + storage.get.calledWith("stateVersion").mockReturnValue(1 as any); + storage.get.calledWith("global").mockReturnValue({ stateVersion: 2 } as any); + expect(await currentVersion(storage, logService)).toEqual(1); + }); +}); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts new file mode 100644 index 00000000000..483c4f2e8eb --- /dev/null +++ b/libs/common/src/state-migrations/migrate.ts @@ -0,0 +1,60 @@ +// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages +import { LogService } from "../platform/abstractions/log.service"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations +import { AbstractStorageService } from "../platform/abstractions/storage.service"; + +import { MigrationBuilder } from "./migration-builder"; +import { MigrationHelper } from "./migration-helper"; +import { FixPremiumMigrator } from "./migrations/3-fix-premium"; +import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; +import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; +import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; +import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; +import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; +import { MinVersionMigrator } from "./migrations/min-version"; + +export const MIN_VERSION = 2; +export const CURRENT_VERSION = 8; +export type MinVersion = typeof MIN_VERSION; + +export async function migrate( + storageService: AbstractStorageService, + logService: LogService +): Promise { + const migrationHelper = new MigrationHelper( + await currentVersion(storageService, logService), + storageService, + logService + ); + if (migrationHelper.currentVersion < 0) { + // Cannot determine state, assuming empty so we don't repeatedly apply a migration. + await storageService.save("stateVersion", CURRENT_VERSION); + return; + } + MigrationBuilder.create() + .with(MinVersionMigrator) + .with(FixPremiumMigrator, 2, 3) + .with(RemoveEverBeenUnlockedMigrator, 3, 4) + .with(AddKeyTypeToOrgKeysMigrator, 4, 5) + .with(RemoveLegacyEtmKeyMigrator, 5, 6) + .with(MoveBiometricAutoPromptToAccount, 6, 7) + .with(MoveStateVersionMigrator, 7, CURRENT_VERSION) + .migrate(migrationHelper); +} + +export async function currentVersion( + storageService: AbstractStorageService, + logService: LogService +) { + let state = await storageService.get("stateVersion"); + if (state == null) { + // Pre v8 + state = (await storageService.get<{ stateVersion: number }>("global"))?.stateVersion; + } + if (state == null) { + logService.info("No state version found, assuming empty state."); + return -1; + } + logService.info(`State version: ${state}`); + return state; +} diff --git a/libs/common/src/state-migrations/migration-builder.spec.ts b/libs/common/src/state-migrations/migration-builder.spec.ts new file mode 100644 index 00000000000..fa53544f133 --- /dev/null +++ b/libs/common/src/state-migrations/migration-builder.spec.ts @@ -0,0 +1,117 @@ +import { mock } from "jest-mock-extended"; + +import { MigrationBuilder } from "./migration-builder"; +import { MigrationHelper } from "./migration-helper"; +import { Migrator } from "./migrator"; + +describe("MigrationBuilder", () => { + class TestMigrator extends Migrator<0, 1> { + async migrate(helper: MigrationHelper): Promise { + await helper.set("test", "test"); + return; + } + + async rollback(helper: MigrationHelper): Promise { + await helper.set("test", "rollback"); + return; + } + } + + let sut: MigrationBuilder; + + beforeEach(() => { + sut = MigrationBuilder.create(); + }); + + class TestBadMigrator extends Migrator<1, 0> { + async migrate(helper: MigrationHelper): Promise { + await helper.set("test", "test"); + } + + async rollback(helper: MigrationHelper): Promise { + await helper.set("test", "rollback"); + } + } + + it("should throw if instantiated incorrectly", () => { + expect(() => MigrationBuilder.create().with(TestMigrator, null, null)).toThrow(); + expect(() => + MigrationBuilder.create().with(TestMigrator, 0, 1).with(TestBadMigrator, 1, 0) + ).toThrow(); + }); + + it("should be able to create a new MigrationBuilder", () => { + expect(sut).toBeInstanceOf(MigrationBuilder); + }); + + it("should be able to add a migrator", () => { + const newBuilder = sut.with(TestMigrator, 0, 1); + const migrations = newBuilder["migrations"]; + expect(migrations.length).toBe(1); + expect(migrations[0]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "up" }); + }); + + it("should be able to add a rollback", () => { + const newBuilder = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0); + const migrations = newBuilder["migrations"]; + expect(migrations.length).toBe(2); + expect(migrations[1]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "down" }); + }); + + describe("migrate", () => { + let migrator: TestMigrator; + let rollback_migrator: TestMigrator; + + beforeEach(() => { + sut = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0); + migrator = (sut as any).migrations[0].migrator; + rollback_migrator = (sut as any).migrations[1].migrator; + }); + + it("should migrate", async () => { + const helper = new MigrationHelper(0, mock(), mock()); + const spy = jest.spyOn(migrator, "migrate"); + await sut.migrate(helper); + expect(spy).toBeCalledWith(helper); + }); + + it("should rollback", async () => { + const helper = new MigrationHelper(1, mock(), mock()); + const spy = jest.spyOn(rollback_migrator, "rollback"); + await sut.migrate(helper); + expect(spy).toBeCalledWith(helper); + }); + + it("should update version on migrate", async () => { + const helper = new MigrationHelper(0, mock(), mock()); + const spy = jest.spyOn(migrator, "updateVersion"); + await sut.migrate(helper); + expect(spy).toBeCalledWith(helper, "up"); + }); + + it("should update version on rollback", async () => { + const helper = new MigrationHelper(1, mock(), mock()); + const spy = jest.spyOn(rollback_migrator, "updateVersion"); + await sut.migrate(helper); + expect(spy).toBeCalledWith(helper, "down"); + }); + + it("should not run the migrator if the current version does not match the from version", async () => { + const helper = new MigrationHelper(3, mock(), mock()); + const migrate = jest.spyOn(migrator, "migrate"); + const rollback = jest.spyOn(rollback_migrator, "rollback"); + await sut.migrate(helper); + expect(migrate).not.toBeCalled(); + expect(rollback).not.toBeCalled(); + }); + + it("should not update version if the current version does not match the from version", async () => { + const helper = new MigrationHelper(3, mock(), mock()); + const migrate = jest.spyOn(migrator, "updateVersion"); + const rollback = jest.spyOn(rollback_migrator, "updateVersion"); + await sut.migrate(helper); + expect(migrate).not.toBeCalled(); + expect(rollback).not.toBeCalled(); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migration-builder.ts b/libs/common/src/state-migrations/migration-builder.ts new file mode 100644 index 00000000000..776295a6b8f --- /dev/null +++ b/libs/common/src/state-migrations/migration-builder.ts @@ -0,0 +1,106 @@ +import { MigrationHelper } from "./migration-helper"; +import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator"; + +export class MigrationBuilder { + /** Create a new MigrationBuilder with an empty buffer of migrations to perform. + * + * Add migrations to the buffer with {@link with} and {@link rollback}. + * @returns A new MigrationBuilder. + */ + static create(): MigrationBuilder<0> { + return new MigrationBuilder([]); + } + + private constructor( + private migrations: readonly { migrator: Migrator; direction: Direction }[] + ) {} + + /** Add a migrator to the MigrationBuilder. Types are updated such that the chained MigrationBuilder must currently be + * at state version equal to the from version of the migrator. Return as MigrationBuilder where TTo is the to + * version of the migrator, so that the next migrator can be chained. + * + * @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is + * required to instantiate version numbers unless a default constructor is defined. + * @returns A new MigrationBuilder with the to version of the migrator as the current version. + */ + with< + TMigrator extends Migrator, + TFrom extends VersionFrom & TCurrent, + TTo extends VersionTo + >( + ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo] + ): MigrationBuilder { + return this.addMigrator(migrate, "up"); + } + + /** Add a migrator to rollback on the MigrationBuilder's list of migrations. As with {@link with}, types of + * MigrationBuilder and Migrator must align. However, this time the migration is reversed so TCurrent of the + * MigrationBuilder must be equal to the to version of the migrator. Return as MigrationBuilder where TFrom + * is the from version of the migrator, so that the next migrator can be chained. + * + * @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is + * required to instantiate version numbers unless a default constructor is defined. + * @returns A new MigrationBuilder with the from version of the migrator as the current version. + */ + rollback< + TMigrator extends Migrator, + TFrom extends VersionFrom, + TTo extends VersionTo & TCurrent + >( + ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom] + ): MigrationBuilder { + if (migrate.length === 3) { + migrate = [migrate[0], migrate[2], migrate[1]]; + } + return this.addMigrator(migrate, "down"); + } + + /** Execute the migrations as defined in the MigrationBuilder's migrator buffer */ + migrate(helper: MigrationHelper): Promise { + return this.migrations.reduce( + (promise, migrator) => + promise.then(async () => { + await this.runMigrator(migrator.migrator, helper, migrator.direction); + }), + Promise.resolve() + ); + } + + private addMigrator< + TMigrator extends Migrator, + TFrom extends VersionFrom & TCurrent, + TTo extends VersionTo + >( + migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo], + direction: Direction = "up" + ) { + const newMigration = + migrate.length === 1 + ? { migrator: new migrate[0](), direction } + : { migrator: new migrate[0](migrate[1], migrate[2]), direction }; + + return new MigrationBuilder([...this.migrations, newMigration]); + } + + private async runMigrator( + migrator: Migrator, + helper: MigrationHelper, + direction: Direction + ): Promise { + const shouldMigrate = await migrator.shouldMigrate(helper, direction); + helper.info( + `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) should migrate: ${shouldMigrate} - ${direction}` + ); + if (shouldMigrate) { + const method = direction === "up" ? migrator.migrate : migrator.rollback; + await method(helper); + helper.info( + `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) migrated - ${direction}` + ); + await migrator.updateVersion(helper, direction); + helper.info( + `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) updated version - ${direction}` + ); + } + } +} diff --git a/libs/common/src/state-migrations/migration-helper.spec.ts b/libs/common/src/state-migrations/migration-helper.spec.ts new file mode 100644 index 00000000000..5b8a0f2eb4f --- /dev/null +++ b/libs/common/src/state-migrations/migration-helper.spec.ts @@ -0,0 +1,84 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages +import { LogService } from "../platform/abstractions/log.service"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations +import { AbstractStorageService } from "../platform/abstractions/storage.service"; + +import { MigrationHelper } from "./migration-helper"; + +const exampleJSON = { + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + otherStuff: "otherStuff1", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + otherStuff: "otherStuff2", + }, +}; + +describe("RemoveLegacyEtmKeyMigrator", () => { + let storage: MockProxy; + let logService: MockProxy; + let sut: MigrationHelper; + + beforeEach(() => { + logService = mock(); + storage = mock(); + storage.get.mockImplementation((key) => (exampleJSON as any)[key]); + + sut = new MigrationHelper(0, storage, logService); + }); + + describe("get", () => { + it("should delegate to storage.get", async () => { + await sut.get("key"); + expect(storage.get).toHaveBeenCalledWith("key"); + }); + }); + + describe("set", () => { + it("should delegate to storage.save", async () => { + await sut.set("key", "value"); + expect(storage.save).toHaveBeenCalledWith("key", "value"); + }); + }); + + describe("getAccounts", () => { + it("should return all accounts", async () => { + const accounts = await sut.getAccounts(); + expect(accounts).toEqual([ + { userId: "c493ed01-4e08-4e88-abc7-332f380ca760", account: { otherStuff: "otherStuff1" } }, + { userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9", account: { otherStuff: "otherStuff2" } }, + ]); + }); + + it("should handle missing authenticatedAccounts", async () => { + storage.get.mockImplementation((key) => + key === "authenticatedAccounts" ? undefined : (exampleJSON as any)[key] + ); + const accounts = await sut.getAccounts(); + expect(accounts).toEqual([]); + }); + }); +}); + +/** Helper to create well-mocked migration helpers in migration tests */ +export function mockMigrationHelper(storageJson: any): MockProxy { + const logService: MockProxy = mock(); + const storage: MockProxy = mock(); + storage.get.mockImplementation((key) => (storageJson as any)[key]); + storage.save.mockImplementation(async (key, value) => { + (storageJson as any)[key] = value; + }); + const helper = new MigrationHelper(0, storage, logService); + + const mockHelper = mock(); + mockHelper.get.mockImplementation((key) => helper.get(key)); + mockHelper.set.mockImplementation((key, value) => helper.set(key, value)); + mockHelper.getAccounts.mockImplementation(() => helper.getAccounts()); + return mockHelper; +} diff --git a/libs/common/src/state-migrations/migration-helper.ts b/libs/common/src/state-migrations/migration-helper.ts new file mode 100644 index 00000000000..a185aa69a99 --- /dev/null +++ b/libs/common/src/state-migrations/migration-helper.ts @@ -0,0 +1,37 @@ +// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages +import { LogService } from "../platform/abstractions/log.service"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations +import { AbstractStorageService } from "../platform/abstractions/storage.service"; + +export class MigrationHelper { + constructor( + public currentVersion: number, + private storageService: AbstractStorageService, + public logService: LogService + ) {} + + get(key: string): Promise { + return this.storageService.get(key); + } + + set(key: string, value: T): Promise { + this.logService.info(`Setting ${key}`); + return this.storageService.save(key, value); + } + + info(message: string): void { + this.logService.info(message); + } + + async getAccounts(): Promise< + { userId: string; account: ExpectedAccountType }[] + > { + const userIds = (await this.get("authenticatedAccounts")) ?? []; + return Promise.all( + userIds.map(async (userId) => ({ + userId, + account: await this.get(userId), + })) + ); + } +} diff --git a/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts b/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts new file mode 100644 index 00000000000..1ef910d4569 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts @@ -0,0 +1,111 @@ +import { MockProxy } from "jest-mock-extended"; + +// eslint-disable-next-line import/no-restricted-paths -- Used for testing migration, which requires import +import { TokenService } from "../../auth/services/token.service"; +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { FixPremiumMigrator } from "./3-fix-premium"; + +function migrateExampleJSON() { + return { + global: { + stateVersion: 2, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + profile: { + otherStuff: "otherStuff2", + hasPremiumPersonally: null as boolean, + }, + tokens: { + otherStuff: "otherStuff3", + accessToken: "accessToken", + }, + otherStuff: "otherStuff4", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + profile: { + otherStuff: "otherStuff5", + hasPremiumPersonally: true, + }, + tokens: { + otherStuff: "otherStuff6", + accessToken: "accessToken", + }, + otherStuff: "otherStuff7", + }, + otherStuff: "otherStuff8", + }; +} + +jest.mock("../../auth/services/token.service", () => ({ + TokenService: { + decodeToken: jest.fn(), + }, +})); + +describe("FixPremiumMigrator", () => { + let helper: MockProxy; + let sut: FixPremiumMigrator; + const decodeTokenSpy = TokenService.decodeToken as jest.Mock; + + beforeEach(() => { + helper = mockMigrationHelper(migrateExampleJSON()); + sut = new FixPremiumMigrator(2, 3); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("migrate", () => { + it("should migrate hasPremiumPersonally", async () => { + decodeTokenSpy.mockResolvedValueOnce({ premium: true }); + await sut.migrate(helper); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + profile: { + otherStuff: "otherStuff2", + hasPremiumPersonally: true, + }, + tokens: { + otherStuff: "otherStuff3", + accessToken: "accessToken", + }, + otherStuff: "otherStuff4", + }); + }); + + it("should not migrate if decode throws", async () => { + decodeTokenSpy.mockRejectedValueOnce(new Error("test")); + await sut.migrate(helper); + + expect(helper.set).not.toHaveBeenCalled(); + }); + + it("should not migrate if decode returns null", async () => { + decodeTokenSpy.mockResolvedValueOnce(null); + await sut.migrate(helper); + + expect(helper.set).not.toHaveBeenCalled(); + }); + }); + + describe("updateVersion", () => { + it("should update version", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 3, + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/3-fix-premium.ts b/libs/common/src/state-migrations/migrations/3-fix-premium.ts new file mode 100644 index 00000000000..b6c69a99168 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/3-fix-premium.ts @@ -0,0 +1,48 @@ +// eslint-disable-next-line import/no-restricted-paths -- Used for token decoding, which are valid for days. We want the latest +import { TokenService } from "../../auth/services/token.service"; +import { MigrationHelper } from "../migration-helper"; +import { Migrator, IRREVERSIBLE, Direction } from "../migrator"; + +type ExpectedAccountType = { + profile?: { hasPremiumPersonally?: boolean }; + tokens?: { accessToken?: string }; +}; + +export class FixPremiumMigrator extends Migrator<2, 3> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function fixPremium(userId: string, account: ExpectedAccountType) { + if (account?.profile?.hasPremiumPersonally === null && account.tokens?.accessToken != null) { + let decodedToken: { premium: boolean }; + try { + decodedToken = await TokenService.decodeToken(account.tokens.accessToken); + } catch { + return; + } + + if (decodedToken?.premium == null) { + return; + } + + account.profile.hasPremiumPersonally = decodedToken?.premium; + return helper.set(userId, account); + } + } + + await Promise.all(accounts.map(({ userId, account }) => fixPremium(userId, account))); + } + + rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version + // it is nested inside a global object. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + const global: Record = (await helper.get("global")) || {}; + await helper.set("global", { ...global, stateVersion: endVersion }); + } +} diff --git a/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts new file mode 100644 index 00000000000..1701762118d --- /dev/null +++ b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts @@ -0,0 +1,75 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { RemoveEverBeenUnlockedMigrator } from "./4-remove-ever-been-unlocked"; + +function migrateExampleJSON() { + return { + global: { + stateVersion: 3, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + profile: { + otherStuff: "otherStuff2", + everBeenUnlocked: true, + }, + otherStuff: "otherStuff3", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + profile: { + otherStuff: "otherStuff4", + everBeenUnlocked: false, + }, + otherStuff: "otherStuff5", + }, + otherStuff: "otherStuff6", + }; +} + +describe("RemoveEverBeenUnlockedMigrator", () => { + let helper: MockProxy; + let sut: RemoveEverBeenUnlockedMigrator; + + beforeEach(() => { + helper = mockMigrationHelper(migrateExampleJSON()); + sut = new RemoveEverBeenUnlockedMigrator(3, 4); + }); + + describe("migrate", () => { + it("should remove everBeenUnlocked from profile", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + profile: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", { + profile: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + }); + }); + + describe("updateVersion", () => { + it("should update version up", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 4, + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts new file mode 100644 index 00000000000..cfa45958d06 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts @@ -0,0 +1,32 @@ +import { MigrationHelper } from "../migration-helper"; +import { Direction, IRREVERSIBLE, Migrator } from "../migrator"; + +type ExpectedAccountType = { profile?: { everBeenUnlocked?: boolean } }; + +export class RemoveEverBeenUnlockedMigrator extends Migrator<3, 4> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function removeEverBeenUnlocked(userId: string, account: ExpectedAccountType) { + if (account?.profile?.everBeenUnlocked != null) { + delete account.profile.everBeenUnlocked; + return helper.set(userId, account); + } + } + + Promise.all(accounts.map(({ userId, account }) => removeEverBeenUnlocked(userId, account))); + } + + rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version + // it is nested inside a global object. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + const global: { stateVersion: number } = (await helper.get("global")) || ({} as any); + await helper.set("global", { ...global, stateVersion: endVersion }); + } +} diff --git a/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts new file mode 100644 index 00000000000..028a0b879b1 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts @@ -0,0 +1,141 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { AddKeyTypeToOrgKeysMigrator } from "./5-add-key-type-to-org-keys"; + +function migrateExampleJSON() { + return { + global: { + stateVersion: 4, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + keys: { + organizationKeys: { + encrypted: { + orgOneId: "orgOneEncKey", + orgTwoId: "orgTwoEncKey", + }, + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + }; +} + +function rollbackExampleJSON() { + return { + global: { + stateVersion: 5, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + keys: { + organizationKeys: { + encrypted: { + orgOneId: { + type: "organization", + key: "orgOneEncKey", + }, + orgTwoId: { + type: "organization", + key: "orgTwoEncKey", + }, + }, + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + }; +} + +describe("AddKeyTypeToOrgKeysMigrator", () => { + let helper: MockProxy; + let sut: AddKeyTypeToOrgKeysMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(migrateExampleJSON()); + sut = new AddKeyTypeToOrgKeysMigrator(4, 5); + }); + + it("should add organization type to organization keys", async () => { + await sut.migrate(helper); + + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + keys: { + organizationKeys: { + encrypted: { + orgOneId: { + type: "organization", + key: "orgOneEncKey", + }, + orgTwoId: { + type: "organization", + key: "orgTwoEncKey", + }, + }, + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should update version", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 5, + otherStuff: "otherStuff1", + }); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackExampleJSON()); + sut = new AddKeyTypeToOrgKeysMigrator(4, 5); + }); + + it("should remove type from orgainzation keys", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + keys: { + organizationKeys: { + encrypted: { + orgOneId: "orgOneEncKey", + orgTwoId: "orgTwoEncKey", + }, + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should update version down", async () => { + await sut.updateVersion(helper, "down"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 4, + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts new file mode 100644 index 00000000000..ab1550c52e3 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts @@ -0,0 +1,67 @@ +import { MigrationHelper } from "../migration-helper"; +import { Direction, Migrator } from "../migrator"; + +type ExpectedAccountType = { keys?: { organizationKeys?: { encrypted: Record } } }; +type NewAccountType = { + keys?: { + organizationKeys?: { encrypted: Record }; + }; +}; + +export class AddKeyTypeToOrgKeysMigrator extends Migrator<4, 5> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function updateOrgKey(userId: string, account: ExpectedAccountType) { + const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted; + if (encryptedOrgKeys == null) { + return; + } + + const newOrgKeys: Record = {}; + + Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => { + newOrgKeys[orgId] = { + type: "organization", + key: encKey, + }; + }); + (account as any).keys.organizationKeys.encrypted = newOrgKeys; + + await helper.set(userId, account); + } + + Promise.all(accounts.map(({ userId, account }) => updateOrgKey(userId, account))); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function updateOrgKey(userId: string, account: NewAccountType) { + const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted; + if (encryptedOrgKeys == null) { + return; + } + + const newOrgKeys: Record = {}; + + Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => { + newOrgKeys[orgId] = encKey.key; + }); + (account as any).keys.organizationKeys.encrypted = newOrgKeys; + + await helper.set(userId, account); + } + + Promise.all(accounts.map(async ({ userId, account }) => updateOrgKey(userId, account))); + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version + // it is nested inside a global object. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + const global: { stateVersion: number } = (await helper.get("global")) || ({} as any); + await helper.set("global", { ...global, stateVersion: endVersion }); + } +} diff --git a/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts new file mode 100644 index 00000000000..bc7b862f6cf --- /dev/null +++ b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts @@ -0,0 +1,80 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { RemoveLegacyEtmKeyMigrator } from "./6-remove-legacy-etm-key"; + +function exampleJSON() { + return { + global: { + stateVersion: 5, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + "fd005ea6-a16a-45ef-ba4a-a194269bfd73", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + keys: { + legacyEtmKey: "legacyEtmKey", + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + keys: { + legacyEtmKey: "legacyEtmKey", + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("RemoveLegacyEtmKeyMigrator", () => { + let helper: MockProxy; + let sut: RemoveLegacyEtmKeyMigrator; + + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON()); + sut = new RemoveLegacyEtmKeyMigrator(5, 6); + }); + + describe("migrate", () => { + it("should remove legacyEtmKey from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + keys: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", { + keys: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + }); + }); + + describe("rollback", () => { + it("should throw", async () => { + await expect(sut.rollback(helper)).rejects.toThrow(); + }); + }); + + describe("updateVersion", () => { + it("should update version up", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 6, + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts new file mode 100644 index 00000000000..2a06916ea33 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts @@ -0,0 +1,32 @@ +import { MigrationHelper } from "../migration-helper"; +import { Direction, IRREVERSIBLE, Migrator } from "../migrator"; + +type ExpectedAccountType = { keys?: { legacyEtmKey?: string } }; + +export class RemoveLegacyEtmKeyMigrator extends Migrator<5, 6> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function updateAccount(userId: string, account: ExpectedAccountType) { + if (account?.keys?.legacyEtmKey) { + delete account.keys.legacyEtmKey; + await helper.set(userId, account); + } + } + + await Promise.all(accounts.map(({ userId, account }) => updateAccount(userId, account))); + } + + async rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version + // it is nested inside a global object. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + const global: { stateVersion: number } = (await helper.get("global")) || ({} as any); + await helper.set("global", { ...global, stateVersion: endVersion }); + } +} diff --git a/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts new file mode 100644 index 00000000000..fe73f8a9bc4 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts @@ -0,0 +1,102 @@ +import { MockProxy, any, matches } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { MoveBiometricAutoPromptToAccount } from "./7-move-biometric-auto-prompt-to-account"; + +function exampleJSON() { + return { + global: { + stateVersion: 6, + noAutoPromptBiometrics: true, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + "fd005ea6-a16a-45ef-ba4a-a194269bfd73", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + settings: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("RemoveLegacyEtmKeyMigrator", () => { + let helper: MockProxy; + let sut: MoveBiometricAutoPromptToAccount; + + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON()); + sut = new MoveBiometricAutoPromptToAccount(6, 7); + }); + + describe("migrate", () => { + it("should remove noAutoPromptBiometrics from global", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("global", { + otherStuff: "otherStuff1", + stateVersion: 6, + }); + }); + + it("should set disableAutoBiometricsPrompt to true on all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + settings: { + disableAutoBiometricsPrompt: true, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", { + settings: { + disableAutoBiometricsPrompt: true, + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + }); + + it("should not set disableAutoBiometricsPrompt to true on accounts if noAutoPromptBiometrics is false", async () => { + const json = exampleJSON(); + json.global.noAutoPromptBiometrics = false; + helper = mockMigrationHelper(json); + await sut.migrate(helper); + expect(helper.set).not.toHaveBeenCalledWith( + matches((s) => s != "global"), + any() + ); + }); + }); + + describe("rollback", () => { + it("should throw", async () => { + await expect(sut.rollback(helper)).rejects.toThrow(); + }); + }); + + describe("updateVersion", () => { + it("should update version up", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith( + "global", + Object.assign({}, exampleJSON().global, { + stateVersion: 7, + }) + ); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts new file mode 100644 index 00000000000..0ac065d60c1 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts @@ -0,0 +1,45 @@ +import { MigrationHelper } from "../migration-helper"; +import { Direction, IRREVERSIBLE, Migrator } from "../migrator"; + +type ExpectedAccountType = { settings?: { disableAutoBiometricsPrompt?: boolean } }; + +export class MoveBiometricAutoPromptToAccount extends Migrator<6, 7> { + async migrate(helper: MigrationHelper): Promise { + const global = await helper.get<{ noAutoPromptBiometrics?: boolean }>("global"); + const noAutoPromptBiometrics = global?.noAutoPromptBiometrics ?? false; + + const accounts = await helper.getAccounts(); + async function updateAccount(userId: string, account: ExpectedAccountType) { + if (account == null) { + return; + } + + if (noAutoPromptBiometrics) { + account.settings = Object.assign(account?.settings ?? {}, { + disableAutoBiometricsPrompt: true, + }); + await helper.set(userId, account); + } + } + + delete global.noAutoPromptBiometrics; + + await Promise.all([ + ...accounts.map(({ userId, account }) => updateAccount(userId, account)), + helper.set("global", global), + ]); + } + + async rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version + // it is nested inside a global object. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + const global: { stateVersion: number } = (await helper.get("global")) || ({} as any); + await helper.set("global", { ...global, stateVersion: endVersion }); + } +} diff --git a/libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts b/libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts new file mode 100644 index 00000000000..8c84fd642ea --- /dev/null +++ b/libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts @@ -0,0 +1,90 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { MoveStateVersionMigrator } from "./8-move-state-version"; + +function migrateExampleJSON() { + return { + global: { + stateVersion: 6, + otherStuff: "otherStuff1", + }, + otherStuff: "otherStuff2", + }; +} + +function rollbackExampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + stateVersion: 7, + otherStuff: "otherStuff2", + }; +} + +describe("moveStateVersion", () => { + let helper: MockProxy; + let sut: MoveStateVersionMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(migrateExampleJSON()); + sut = new MoveStateVersionMigrator(7, 8); + }); + + it("should move state version to root", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("stateVersion", 6); + }); + + it("should remove state version from global", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("global", { + otherStuff: "otherStuff1", + }); + }); + + it("should throw if state version not found", async () => { + helper.get.mockReturnValue({ otherStuff: "otherStuff1" } as any); + await expect(sut.migrate(helper)).rejects.toThrow( + "Migration failed, state version not found" + ); + }); + + it("should update version up", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("stateVersion", 8); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackExampleJSON()); + sut = new MoveStateVersionMigrator(7, 8); + }); + + it("should move state version to global", async () => { + await sut.rollback(helper); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 7, + otherStuff: "otherStuff1", + }); + expect(helper.set).toHaveBeenCalledWith("stateVersion", undefined); + }); + + it("should update version down", async () => { + await sut.updateVersion(helper, "down"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 7, + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/8-move-state-version.ts b/libs/common/src/state-migrations/migrations/8-move-state-version.ts new file mode 100644 index 00000000000..cbcdf423843 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/8-move-state-version.ts @@ -0,0 +1,37 @@ +import { JsonObject } from "type-fest"; + +import { MigrationHelper } from "../migration-helper"; +import { Direction, Migrator } from "../migrator"; + +export class MoveStateVersionMigrator extends Migrator<7, 8> { + async migrate(helper: MigrationHelper): Promise { + const global = await helper.get<{ stateVersion: number }>("global"); + if (global.stateVersion) { + await helper.set("stateVersion", global.stateVersion); + delete global.stateVersion; + await helper.set("global", global); + } else { + throw new Error("Migration failed, state version not found"); + } + } + + async rollback(helper: MigrationHelper): Promise { + const version = await helper.get("stateVersion"); + const global = await helper.get("global"); + await helper.set("global", { ...global, stateVersion: version }); + await helper.set("stateVersion", undefined); + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but this migration moves + // it from a `global` object to root.This makes for unique rollback versioning. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + if (direction === "up") { + await helper.set("stateVersion", endVersion); + } else { + const global: { stateVersion: number } = (await helper.get("global")) || ({} as any); + await helper.set("global", { ...global, stateVersion: endVersion }); + } + } +} diff --git a/libs/common/src/state-migrations/migrations/min-version.spec.ts b/libs/common/src/state-migrations/migrations/min-version.spec.ts new file mode 100644 index 00000000000..26e106c19a9 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/min-version.spec.ts @@ -0,0 +1,29 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MIN_VERSION } from "../migrate"; +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { MinVersionMigrator } from "./min-version"; + +describe("MinVersionMigrator", () => { + let helper: MockProxy; + let sut: MinVersionMigrator; + + beforeEach(() => { + helper = mockMigrationHelper(null); + sut = new MinVersionMigrator(); + }); + + describe("shouldMigrate", () => { + it("should return true if current version is less than min version", async () => { + helper.currentVersion = MIN_VERSION - 1; + expect(await sut.shouldMigrate(helper)).toBe(true); + }); + + it("should return false if current version is greater than min version", async () => { + helper.currentVersion = MIN_VERSION + 1; + expect(await sut.shouldMigrate(helper)).toBe(false); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/min-version.ts b/libs/common/src/state-migrations/migrations/min-version.ts new file mode 100644 index 00000000000..a417cc51a3c --- /dev/null +++ b/libs/common/src/state-migrations/migrations/min-version.ts @@ -0,0 +1,26 @@ +import { MinVersion, MIN_VERSION } from "../migrate"; +import { MigrationHelper } from "../migration-helper"; +import { IRREVERSIBLE, Migrator } from "../migrator"; + +export function minVersionError(current: number) { + return `Your local data is too old to be migrated. Your current state version is ${current}, but minimum version is ${MIN_VERSION}.`; +} + +export class MinVersionMigrator extends Migrator<0, MinVersion> { + constructor() { + super(0, MIN_VERSION); + } + + // Overrides the default implementation to catch any version that may be passed in. + override shouldMigrate(helper: MigrationHelper): Promise { + return Promise.resolve(helper.currentVersion < MIN_VERSION); + } + async migrate(helper: MigrationHelper): Promise { + if (helper.currentVersion < MIN_VERSION) { + throw new Error(minVersionError(helper.currentVersion)); + } + } + async rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } +} diff --git a/libs/common/src/state-migrations/migrator.spec.ts b/libs/common/src/state-migrations/migrator.spec.ts new file mode 100644 index 00000000000..3abaa277273 --- /dev/null +++ b/libs/common/src/state-migrations/migrator.spec.ts @@ -0,0 +1,75 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages +import { LogService } from "../platform/abstractions/log.service"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations +import { AbstractStorageService } from "../platform/abstractions/storage.service"; + +import { MigrationHelper } from "./migration-helper"; +import { Migrator } from "./migrator"; + +describe("migrator default methods", () => { + class TestMigrator extends Migrator<0, 1> { + async migrate(helper: MigrationHelper): Promise { + await helper.set("test", "test"); + } + async rollback(helper: MigrationHelper): Promise { + await helper.set("test", "rollback"); + } + } + + let storage: MockProxy; + let logService: MockProxy; + let helper: MigrationHelper; + let sut: TestMigrator; + + beforeEach(() => { + storage = mock(); + logService = mock(); + helper = new MigrationHelper(0, storage, logService); + sut = new TestMigrator(0, 1); + }); + + describe("shouldMigrate", () => { + describe("up", () => { + it("should return true if the current version equals the from version", async () => { + expect(await sut.shouldMigrate(helper, "up")).toBe(true); + }); + + it("should return false if the current version does not equal the from version", async () => { + helper.currentVersion = 1; + expect(await sut.shouldMigrate(helper, "up")).toBe(false); + }); + }); + + describe("down", () => { + it("should return true if the current version equals the to version", async () => { + helper.currentVersion = 1; + expect(await sut.shouldMigrate(helper, "down")).toBe(true); + }); + + it("should return false if the current version does not equal the to version", async () => { + expect(await sut.shouldMigrate(helper, "down")).toBe(false); + }); + }); + }); + + describe("updateVersion", () => { + describe("up", () => { + it("should update the version", async () => { + await sut.updateVersion(helper, "up"); + expect(storage.save).toBeCalledWith("stateVersion", 1); + expect(helper.currentVersion).toBe(1); + }); + }); + + describe("down", () => { + it("should update the version", async () => { + helper.currentVersion = 1; + await sut.updateVersion(helper, "down"); + expect(storage.save).toBeCalledWith("stateVersion", 0); + expect(helper.currentVersion).toBe(0); + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrator.ts b/libs/common/src/state-migrations/migrator.ts new file mode 100644 index 00000000000..aba81372d49 --- /dev/null +++ b/libs/common/src/state-migrations/migrator.ts @@ -0,0 +1,40 @@ +import { NonNegativeInteger } from "type-fest"; + +import { MigrationHelper } from "./migration-helper"; + +export const IRREVERSIBLE = new Error("Irreversible migration"); + +export type VersionFrom = T extends Migrator + ? TFrom extends NonNegativeInteger + ? TFrom + : never + : never; +export type VersionTo = T extends Migrator + ? TTo extends NonNegativeInteger + ? TTo + : never + : never; +export type Direction = "up" | "down"; + +export abstract class Migrator { + constructor(public fromVersion: TFrom, public toVersion: TTo) { + if (fromVersion == null || toVersion == null) { + throw new Error("Invalid migration"); + } + if (fromVersion > toVersion) { + throw new Error("Invalid migration"); + } + } + + shouldMigrate(helper: MigrationHelper, direction: Direction): Promise { + const startVersion = direction === "up" ? this.fromVersion : this.toVersion; + return Promise.resolve(helper.currentVersion === startVersion); + } + abstract migrate(helper: MigrationHelper): Promise; + abstract rollback(helper: MigrationHelper): Promise; + async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + await helper.set("stateVersion", endVersion); + } +}