diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index dc58e54cfc1..ebac08a0ab0 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -344,6 +344,11 @@ export default class MainBackground { this.globalStateProvider, this.derivedStateProvider, ); + this.environmentService = new BrowserEnvironmentService( + this.logService, + this.stateProvider, + this.accountService, + ); this.stateService = new BrowserStateService( this.storageService, this.secureStorageService, @@ -351,6 +356,7 @@ export default class MainBackground { this.logService, new StateFactory(GlobalState, Account), this.accountService, + this.environmentService, ); this.platformUtilsService = new BrowserPlatformUtilsService( this.messagingService, @@ -386,7 +392,6 @@ export default class MainBackground { ); this.tokenService = new TokenService(this.stateService); this.appIdService = new AppIdService(this.storageService); - this.environmentService = new BrowserEnvironmentService(this.stateService, this.logService); this.apiService = new ApiService( this.tokenService, this.platformUtilsService, diff --git a/apps/browser/src/platform/background/service-factories/environment-service.factory.ts b/apps/browser/src/platform/background/service-factories/environment-service.factory.ts index 4ea7dac225c..38ab62de482 100644 --- a/apps/browser/src/platform/background/service-factories/environment-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/environment-service.factory.ts @@ -1,16 +1,18 @@ +import { + accountServiceFactory, + AccountServiceInitOptions, +} from "../../../auth/background/service-factories/account-service.factory"; import { BrowserEnvironmentService } from "../../services/browser-environment.service"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; -import { - stateServiceFactory as stateServiceFactory, - StateServiceInitOptions, -} from "./state-service.factory"; +import { stateProviderFactory, StateProviderInitOptions } from "./state-provider.factory"; type EnvironmentServiceFactoryOptions = FactoryOptions; export type EnvironmentServiceInitOptions = EnvironmentServiceFactoryOptions & - StateServiceInitOptions & + StateProviderInitOptions & + AccountServiceInitOptions & LogServiceInitOptions; export function environmentServiceFactory( @@ -23,8 +25,9 @@ export function environmentServiceFactory( opts, async () => new BrowserEnvironmentService( - await stateServiceFactory(cache, opts), await logServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), + await accountServiceFactory(cache, opts), ), ); } 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 3fc5aedefb7..c4d76ad5c8f 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 @@ -8,6 +8,10 @@ import { import { Account } from "../../../models/account"; import { BrowserStateService } from "../../services/browser-state.service"; +import { + environmentServiceFactory, + EnvironmentServiceInitOptions, +} from "./environment-service.factory"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; import { @@ -31,7 +35,8 @@ export type StateServiceInitOptions = StateServiceFactoryOptions & SecureStorageServiceInitOptions & MemoryStorageServiceInitOptions & LogServiceInitOptions & - AccountServiceInitOptions; + AccountServiceInitOptions & + EnvironmentServiceInitOptions; export async function stateServiceFactory( cache: { stateService?: BrowserStateService } & CachedServices, @@ -42,13 +47,14 @@ export async function stateServiceFactory( "stateService", opts, async () => - await new BrowserStateService( + new BrowserStateService( await diskStorageServiceFactory(cache, opts), await secureStorageServiceFactory(cache, opts), await memoryStorageServiceFactory(cache, opts), await logServiceFactory(cache, opts), opts.stateServiceOptions.stateFactory, await accountServiceFactory(cache, opts), + await environmentServiceFactory(cache, opts), opts.stateServiceOptions.useAccountCache, ), ); diff --git a/apps/browser/src/platform/services/browser-environment.service.ts b/apps/browser/src/platform/services/browser-environment.service.ts index db716baf4a3..77cf7de8ead 100644 --- a/apps/browser/src/platform/services/browser-environment.service.ts +++ b/apps/browser/src/platform/services/browser-environment.service.ts @@ -1,16 +1,18 @@ +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { GroupPolicyEnvironment } from "../../admin-console/types/group-policy-environment"; import { devFlagEnabled, devFlagValue } from "../flags"; export class BrowserEnvironmentService extends EnvironmentService { constructor( - stateService: StateService, private logService: LogService, + stateProvider: StateProvider, + accountService: AccountService, ) { - super(stateService); + super(stateProvider, accountService); } async hasManagedEnvironment(): Promise { 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 08393ca0de3..b421f55a064 100644 --- a/apps/browser/src/platform/services/browser-state.service.spec.ts +++ b/apps/browser/src/platform/services/browser-state.service.spec.ts @@ -1,6 +1,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractMemoryStorageService, @@ -29,6 +30,7 @@ describe("Browser State Service", () => { let stateFactory: MockProxy>; let useAccountCache: boolean; let accountService: MockProxy; + let environmentService: MockProxy; let state: State; const userId = "userId"; @@ -41,6 +43,7 @@ describe("Browser State Service", () => { logService = mock(); stateFactory = mock(); accountService = mock(); + environmentService = mock(); // turn off account cache for tests useAccountCache = false; @@ -66,6 +69,7 @@ describe("Browser State Service", () => { logService, stateFactory, accountService, + environmentService, useAccountCache, ); }); diff --git a/apps/browser/src/platform/services/browser-state.service.ts b/apps/browser/src/platform/services/browser-state.service.ts index d5cba8fa9d3..6641d1e81a7 100644 --- a/apps/browser/src/platform/services/browser-state.service.ts +++ b/apps/browser/src/platform/services/browser-state.service.ts @@ -1,6 +1,7 @@ import { BehaviorSubject } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractStorageService, @@ -44,6 +45,7 @@ export class BrowserStateService logService: LogService, stateFactory: StateFactory, accountService: AccountService, + environmentService: EnvironmentService, useAccountCache = true, ) { super( @@ -53,6 +55,7 @@ export class BrowserStateService logService, stateFactory, accountService, + environmentService, useAccountCache, ); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index f4db8f0d437..9a8484e8a26 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -483,6 +483,7 @@ function getBgService(service: keyof MainBackground) { memoryStorageService: AbstractMemoryStorageService, logService: LogServiceAbstraction, accountService: AccountServiceAbstraction, + environmentService: EnvironmentService, ) => { return new BrowserStateService( storageService, @@ -491,6 +492,7 @@ function getBgService(service: keyof MainBackground) { logService, new StateFactory(GlobalState, Account), accountService, + environmentService, ); }, deps: [ @@ -499,6 +501,7 @@ function getBgService(service: keyof MainBackground) { MEMORY_STORAGE, LogServiceAbstraction, AccountServiceAbstraction, + EnvironmentService, ], }, { diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index b4d62f952a9..850fec07879 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -255,6 +255,8 @@ export class Main { this.derivedStateProvider, ); + this.environmentService = new EnvironmentService(this.stateProvider, this.accountService); + this.stateService = new StateService( this.storageService, this.secureStorageService, @@ -262,6 +264,7 @@ export class Main { this.logService, new StateFactory(GlobalState, Account), this.accountService, + this.environmentService, ); this.cryptoService = new CryptoService( @@ -276,7 +279,6 @@ export class Main { this.appIdService = new AppIdService(this.storageService); this.tokenService = new TokenService(this.stateService); - this.environmentService = new EnvironmentService(this.stateService); const customUserAgent = "Bitwarden_CLI/" + diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index eab6c83d559..75b2b111853 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -22,6 +22,7 @@ import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/ import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { @@ -129,6 +130,7 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); LogService, STATE_FACTORY, AccountServiceAbstraction, + EnvironmentService, STATE_SERVICE_USE_CACHE, ], }, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0dbd818f622..82ebde92858 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,10 +5,16 @@ import { app } from "electron"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; +import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service"; -// eslint-disable-next-line import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed +/* eslint-disable import/no-restricted-paths -- We need the implementation to inject, but generally this should not be accessed */ +import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider"; +import { DefaultDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/default-derived-state.provider"; import { DefaultGlobalStateProvider } from "@bitwarden/common/platform/state/implementations/default-global-state.provider"; +import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-single-user-state.provider"; +import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider"; +/*/ eslint-enable import/no-restricted-paths */ import { MenuMain } from "./main/menu/menu.main"; import { MessagingMain } from "./main/messaging.main"; @@ -34,6 +40,7 @@ export class Main { memoryStorageService: MemoryStorageService; messagingService: ElectronMainMessagingService; stateService: ElectronStateService; + environmentService: EnvironmentService; desktopCredentialStorageListener: DesktopCredentialStorageListener; windowMain: WindowMain; @@ -93,6 +100,25 @@ export class Main { this.storageService, ); + const accountService = new AccountServiceImplementation( + new NoopMessagingService(), + this.logService, + globalStateProvider, + ); + + const stateProvider = new DefaultStateProvider( + new DefaultActiveUserStateProvider( + accountService, + this.memoryStorageService, + this.storageService, + ), + new DefaultSingleUserStateProvider(this.memoryStorageService, this.storageService), + globalStateProvider, + new DefaultDerivedStateProvider(this.memoryStorageService), + ); + + this.environmentService = new EnvironmentService(stateProvider, accountService); + // TODO: this state service will have access to on disk storage, but not in memory storage. // If we could get this to work using the stateService singleton that the rest of the app uses we could save // ourselves from some hacks, like having to manually update the app menu vs. the menu subscribing to events. @@ -102,11 +128,8 @@ export class Main { this.memoryStorageService, this.logService, new StateFactory(GlobalState, Account), - new AccountServiceImplementation( - new NoopMessagingService(), - this.logService, - globalStateProvider, - ), // will not broadcast logouts. This is a hack until we can remove messaging dependency + accountService, // will not broadcast logouts. This is a hack until we can remove messaging dependency + this.environmentService, false, // Do not use disk caching because this will get out of sync with the renderer service ); @@ -128,7 +151,7 @@ export class Main { this.menuMain = new MenuMain( this.i18nService, this.messagingService, - this.stateService, + this.environmentService, this.windowMain, this.updaterMain, ); diff --git a/apps/desktop/src/main/menu/menu.main.ts b/apps/desktop/src/main/menu/menu.main.ts index c728ae992f6..413583982f7 100644 --- a/apps/desktop/src/main/menu/menu.main.ts +++ b/apps/desktop/src/main/menu/menu.main.ts @@ -1,8 +1,8 @@ import { app, Menu } from "electron"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { UpdaterMain } from "../updater.main"; import { WindowMain } from "../window.main"; @@ -16,7 +16,7 @@ export class MenuMain { constructor( private i18nService: I18nService, private messagingService: MessagingService, - private stateService: StateService, + private environmentService: EnvironmentService, private windowMain: WindowMain, private updaterMain: UpdaterMain, ) {} @@ -45,16 +45,7 @@ export class MenuMain { } private async getWebVaultUrl() { - let webVaultUrl = cloudWebVaultUrl; - const urlsObj = await this.stateService.getEnvironmentUrls(); - if (urlsObj != null) { - if (urlsObj.base != null) { - webVaultUrl = urlsObj.base; - } else if (urlsObj.webVault != null) { - webVaultUrl = urlsObj.webVault; - } - } - return webVaultUrl; + return this.environmentService.getWebVaultUrl() ?? cloudWebVaultUrl; } private initContextMenu() { diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts index d6cee29388d..838cb0e809f 100644 --- a/apps/web/src/app/core/state/state.service.ts +++ b/apps/web/src/app/core/state/state.service.ts @@ -7,6 +7,7 @@ import { STATE_SERVICE_USE_CACHE, } from "@bitwarden/angular/services/injection-tokens"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractMemoryStorageService, @@ -32,6 +33,7 @@ export class StateService extends BaseStateService { logService: LogService, @Inject(STATE_FACTORY) stateFactory: StateFactory, accountService: AccountService, + environmentService: EnvironmentService, @Inject(STATE_SERVICE_USE_CACHE) useAccountCache = true, ) { super( @@ -41,6 +43,7 @@ export class StateService extends BaseStateService { logService, stateFactory, accountService, + environmentService, useAccountCache, ); } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index a18605de103..e1e8e937491 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -382,7 +382,7 @@ import { ModalService } from "./modal.service"; { provide: EnvironmentServiceAbstraction, useClass: EnvironmentService, - deps: [StateServiceAbstraction], + deps: [StateProvider, AccountServiceAbstraction], }, { provide: TotpServiceAbstraction, @@ -515,6 +515,7 @@ import { ModalService } from "./modal.service"; LogService, STATE_FACTORY, AccountServiceAbstraction, + EnvironmentServiceAbstraction, STATE_SERVICE_USE_CACHE, ], }, diff --git a/libs/common/src/platform/abstractions/environment.service.ts b/libs/common/src/platform/abstractions/environment.service.ts index 6b8d40c86b7..944c1f51331 100644 --- a/libs/common/src/platform/abstractions/environment.service.ts +++ b/libs/common/src/platform/abstractions/environment.service.ts @@ -1,5 +1,7 @@ import { Observable } from "rxjs"; +import { UserId } from "../../types/guid"; + export type Urls = { base?: string; webVault?: string; @@ -52,6 +54,12 @@ export abstract class EnvironmentService { * @param {Region} region - The region of the cloud web vault app. */ setCloudWebVaultUrl: (region: Region) => void; + + /** + * Seed the environment for a given user based on the globally set defaults. + */ + seedUserEnvironment: (userId: UserId) => Promise; + getSendUrl: () => string; getIconsUrl: () => string; getApiUrl: () => string; diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index b21238ee0b7..c7d6d6c09d4 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -5,7 +5,6 @@ import { PolicyData } from "../../admin-console/models/data/policy.data"; import { ProviderData } from "../../admin-console/models/data/provider.data"; import { Policy } from "../../admin-console/models/domain/policy"; import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; -import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; @@ -378,10 +377,6 @@ export abstract class StateService { setEntityId: (value: string, options?: StorageOptions) => Promise; getEntityType: (options?: StorageOptions) => Promise; setEntityType: (value: string, options?: StorageOptions) => Promise; - getEnvironmentUrls: (options?: StorageOptions) => Promise; - setEnvironmentUrls: (value: EnvironmentUrls, options?: StorageOptions) => Promise; - getRegion: (options?: StorageOptions) => Promise; - setRegion: (value: string, options?: StorageOptions) => Promise; getEquivalentDomains: (options?: StorageOptions) => Promise; setEquivalentDomains: (value: string, options?: StorageOptions) => Promise; getEventCollection: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 10b7ebbd160..6dbade6db69 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -5,7 +5,6 @@ import { PolicyData } from "../../../admin-console/models/data/policy.data"; import { ProviderData } from "../../../admin-console/models/data/provider.data"; import { Policy } from "../../../admin-console/models/domain/policy"; import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable"; -import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/key-connector-user-decryption-option"; import { TrustedDeviceUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/trusted-device-user-decryption-option"; @@ -239,7 +238,6 @@ export class AccountSettings { enableAutoFillOnPageLoad?: boolean; enableBiometric?: boolean; enableFullWidth?: boolean; - environmentUrls: EnvironmentUrls = new EnvironmentUrls(); equivalentDomains?: any; minimizeOnCopyToClipboard?: boolean; passwordGenerationOptions?: PasswordGeneratorOptions; @@ -255,7 +253,6 @@ export class AccountSettings { approveLoginRequests?: boolean; avatarColor?: string; activateAutoFillOnPageLoadFromPolicy?: boolean; - region?: string; smOnboardingTasks?: Record>; trustDeviceChoiceForDecryption?: boolean; biometricPromptCancelled?: boolean; @@ -269,7 +266,6 @@ export class AccountSettings { } return Object.assign(new AccountSettings(), obj, { - environmentUrls: EnvironmentUrls.fromJSON(obj?.environmentUrls), pinProtected: EncryptionPair.fromJSON( obj?.pinProtected, EncString.fromJSON, diff --git a/libs/common/src/platform/models/domain/global-state.ts b/libs/common/src/platform/models/domain/global-state.ts index c5bba532cc0..63ef92457a0 100644 --- a/libs/common/src/platform/models/domain/global-state.ts +++ b/libs/common/src/platform/models/domain/global-state.ts @@ -1,4 +1,3 @@ -import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls"; import { WindowState } from "../../../models/domain/window-state"; import { ThemeType } from "../../enums"; @@ -24,7 +23,6 @@ export class GlobalState { enableBiometrics?: boolean; biometricText?: string; noAutoPromptBiometricsText?: string; - environmentUrls: EnvironmentUrls = new EnvironmentUrls(); enableTray?: boolean; enableMinimizeToTray?: boolean; enableCloseToTray?: boolean; @@ -34,7 +32,6 @@ export class GlobalState { enableBrowserIntegration?: boolean; enableBrowserIntegrationFingerprint?: boolean; enableDuckDuckGoBrowserIntegration?: boolean; - region?: string; neverDomains?: { [id: string]: unknown }; enablePasskeys?: boolean; disableAddLoginNotification?: boolean; diff --git a/libs/common/src/platform/services/environment.service.spec.ts b/libs/common/src/platform/services/environment.service.spec.ts index 96457f7638b..a474e469a27 100644 --- a/libs/common/src/platform/services/environment.service.spec.ts +++ b/libs/common/src/platform/services/environment.service.spec.ts @@ -1,17 +1,22 @@ -import { mock } from "jest-mock-extended"; import { firstValueFrom, timeout } from "rxjs"; import { awaitAsync } from "../../../spec"; +import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { FakeStorageService } from "../../../spec/fake-storage.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; import { UserId } from "../../types/guid"; import { Region } from "../abstractions/environment.service"; -import { StateFactory } from "../factories/state-factory"; -import { Account } from "../models/domain/account"; -import { GlobalState } from "../models/domain/global-state"; +import { StateProvider } from "../state"; +/* eslint-disable import/no-restricted-paths -- Rare testing need */ +import { DefaultActiveUserStateProvider } from "../state/implementations/default-active-user-state.provider"; +import { DefaultDerivedStateProvider } from "../state/implementations/default-derived-state.provider"; +import { DefaultGlobalStateProvider } from "../state/implementations/default-global-state.provider"; +import { DefaultSingleUserStateProvider } from "../state/implementations/default-single-user-state.provider"; +import { DefaultStateProvider } from "../state/implementations/default-state.provider"; +/* eslint-disable import/no-restricted-paths */ import { EnvironmentService } from "./environment.service"; -import { StateService } from "./state.service"; // There are a few main states EnvironmentService could be in when first used // 1. Not initialized, no active user. Hopefully not to likely but possible @@ -21,51 +26,55 @@ import { StateService } from "./state.service"; describe("EnvironmentService", () => { let diskStorageService: FakeStorageService; let memoryStorageService: FakeStorageService; - let stateService: StateService; + let accountService: FakeAccountService; + let stateProvider: StateProvider; let sut: EnvironmentService; - const testUser = "testUser1" as UserId; + const testUser = "00000000-0000-1000-a000-000000000001" as UserId; + const alternateTestUser = "00000000-0000-1000-a000-000000000002" as UserId; - // START: CAN CHANGE - When implementing new storage locations, the following code can change. But not tests beforeEach(async () => { diskStorageService = new FakeStorageService(); memoryStorageService = new FakeStorageService(); - stateService = new StateService( - diskStorageService, - null, - memoryStorageService as any, - mock(), - new StateFactory(GlobalState, Account), - mock(), - false, + + accountService = mockAccountServiceWith(undefined); + stateProvider = new DefaultStateProvider( + new DefaultActiveUserStateProvider( + accountService, + memoryStorageService as any, + diskStorageService, + ), + new DefaultSingleUserStateProvider(memoryStorageService as any, diskStorageService), + new DefaultGlobalStateProvider(memoryStorageService as any, diskStorageService), + new DefaultDerivedStateProvider(memoryStorageService), ); - sut = new EnvironmentService(stateService); + sut = new EnvironmentService(stateProvider, accountService); }); const switchUser = async (userId: UserId) => { - await stateService.setActiveUser(userId); + accountService.activeAccountSubject.next({ + id: userId, + email: "test@example.com", + name: `Test Name ${userId}`, + status: AuthenticationStatus.Unlocked, + }); await awaitAsync(); }; const setGlobalData = (region: Region, environmentUrls: EnvironmentUrls) => { - diskStorageService.internalUpdateStore({ - ...diskStorageService.internalStore, - global: { - region: region, - environmentUrls: environmentUrls, - }, - }); + const data = diskStorageService.internalStore; + data["global_environment_region"] = region; + data["global_environment_urls"] = environmentUrls; + diskStorageService.internalUpdateStore(data); }; const getGlobalData = () => { - const storage = diskStorageService.internalStore as { - global?: { region?: Region; environmentUrls?: EnvironmentUrls }; - }; + const storage = diskStorageService.internalStore; return { - region: storage?.global?.region, - urls: storage?.global?.environmentUrls, + region: storage?.["global_environment_region"], + urls: storage?.["global_environment_urls"], }; }; @@ -74,22 +83,15 @@ describe("EnvironmentService", () => { environmentUrls: EnvironmentUrls, userId: UserId = testUser, ) => { - const data = { ...diskStorageService.internalStore }; - const userData = { - settings: { - region: region, - environmentUrls: environmentUrls, - }, - }; - data[userId] = userData; + const data = diskStorageService.internalStore; + data[`user_${userId}_environment_region`] = region; + data[`user_${userId}_environment_urls`] = environmentUrls; diskStorageService.internalUpdateStore(data); }; // END: CAN CHANGE const initialize = async (options: { switchUser: boolean }) => { - // This emulates the way EnvironmentService is initialized in each of our clients - await stateService.init(); await sut.setUrlsFromStorage(); sut.initialized = true; @@ -292,7 +294,7 @@ describe("EnvironmentService", () => { }); const globalData = getGlobalData(); - expect(globalData.region).toBe("Self-hosted"); + expect(globalData.region).toBe(Region.SelfHosted); expect(globalData.urls).toEqual({ base: "https://base.example.com", api: null, @@ -321,7 +323,7 @@ describe("EnvironmentService", () => { }); const globalData = getGlobalData(); - expect(globalData.region).toBe("Self-hosted"); + expect(globalData.region).toBe(Region.SelfHosted); expect(globalData.urls).toEqual({ base: "https://base.example.com", api: "https://api.example.com", @@ -399,15 +401,13 @@ describe("EnvironmentService", () => { ])( "gets it from the passed in userId if there is any active user: %s", async ({ region, expectedHost }) => { - const otherUser = "testUser2" as UserId; - setGlobalData(Region.US, new EnvironmentUrls()); setUserData(Region.US, new EnvironmentUrls()); - setUserData(region, new EnvironmentUrls(), otherUser); + setUserData(region, new EnvironmentUrls(), alternateTestUser); await initialize({ switchUser: true }); - const host = await sut.getHost(otherUser); + const host = await sut.getHost(alternateTestUser); expect(host).toBe(expectedHost); }, ); @@ -438,17 +438,16 @@ describe("EnvironmentService", () => { }); it("gets it from saved self host config from passed in user when there is an active user", async () => { - const otherUser = "testUser2" as UserId; setGlobalData(Region.US, new EnvironmentUrls()); setUserData(Region.EU, new EnvironmentUrls()); const selfHostUserUrls = new EnvironmentUrls(); selfHostUserUrls.base = "https://base.example.com"; - setUserData(Region.SelfHosted, selfHostUserUrls, otherUser); + setUserData(Region.SelfHosted, selfHostUserUrls, alternateTestUser); await initialize({ switchUser: true }); - const host = await sut.getHost(otherUser); + const host = await sut.getHost(alternateTestUser); expect(host).toBe("base.example.com"); }); }); @@ -498,7 +497,6 @@ describe("EnvironmentService", () => { }); it("will get urls from signed in user", async () => { - await stateService.init(); await switchUser(testUser); const userUrls = new EnvironmentUrls(); diff --git a/libs/common/src/platform/services/environment.service.ts b/libs/common/src/platform/services/environment.service.ts index 7a6c3cd6ffb..ba39ec40259 100644 --- a/libs/common/src/platform/services/environment.service.ts +++ b/libs/common/src/platform/services/environment.service.ts @@ -1,14 +1,31 @@ -import { concatMap, distinctUntilChanged, Observable, ReplaySubject } from "rxjs"; +import { + concatMap, + distinctUntilChanged, + firstValueFrom, + map, + Observable, + ReplaySubject, +} from "rxjs"; +import { AccountService } from "../../auth/abstractions/account.service"; import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; +import { UserId } from "../../types/guid"; import { EnvironmentService as EnvironmentServiceAbstraction, Region, RegionDomain, Urls, } from "../abstractions/environment.service"; -import { StateService } from "../abstractions/state.service"; import { Utils } from "../misc/utils"; +import { ENVIRONMENT_DISK, GlobalState, KeyDefinition, StateProvider } from "../state"; + +const REGION_KEY = new KeyDefinition(ENVIRONMENT_DISK, "region", { + deserializer: (s) => s, +}); + +const URLS_KEY = new KeyDefinition(ENVIRONMENT_DISK, "urls", { + deserializer: EnvironmentUrls.fromJSON, +}); export class EnvironmentService implements EnvironmentServiceAbstraction { private readonly urlsSubject = new ReplaySubject(1); @@ -27,6 +44,11 @@ export class EnvironmentService implements EnvironmentServiceAbstraction { private scimUrl: string = null; private cloudWebVaultUrl: string; + private regionGlobalState: GlobalState; + private urlsGlobalState: GlobalState; + + private activeAccountId$: Observable; + readonly usUrls: Urls = { base: null, api: "https://api.bitwarden.com", @@ -49,8 +71,15 @@ export class EnvironmentService implements EnvironmentServiceAbstraction { scim: "https://scim.bitwarden.eu", }; - constructor(private stateService: StateService) { - this.stateService.activeAccount$ + constructor( + private stateProvider: StateProvider, + private accountService: AccountService, + ) { + // We intentionally don't want the helper on account service, we want the null back if there is no active user + this.activeAccountId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); + + // TODO: Get rid of early subscription during EnvironmentService refactor + this.activeAccountId$ .pipe( // Use == here to not trigger on undefined -> null transition distinctUntilChanged((oldUserId: string, newUserId: string) => oldUserId == newUserId), @@ -62,6 +91,9 @@ export class EnvironmentService implements EnvironmentServiceAbstraction { }), ) .subscribe(); + + this.regionGlobalState = this.stateProvider.getGlobal(REGION_KEY); + this.urlsGlobalState = this.stateProvider.getGlobal(URLS_KEY); } hasBaseUrl() { @@ -180,8 +212,10 @@ export class EnvironmentService implements EnvironmentServiceAbstraction { } async setUrlsFromStorage(): Promise { - const region = await this.stateService.getRegion(); - const savedUrls = await this.stateService.getEnvironmentUrls(); + const activeUserId = await firstValueFrom(this.activeAccountId$); + + const region = await this.getRegion(activeUserId); + const savedUrls = await this.getEnvironmentUrls(activeUserId); const envUrls = new EnvironmentUrls(); // In release `2023.5.0`, we set the `base` property of the environment URLs to the US web vault URL when a user clicked the "US" region. @@ -231,7 +265,8 @@ export class EnvironmentService implements EnvironmentServiceAbstraction { // scimUrl cannot be cleared urls.scim = this.formatUrl(urls.scim) ?? this.scimUrl; - await this.stateService.setEnvironmentUrls({ + // Don't save scim url + await this.urlsGlobalState.update(() => ({ base: urls.base, api: urls.api, identity: urls.identity, @@ -240,8 +275,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction { notifications: urls.notifications, events: urls.events, keyConnector: urls.keyConnector, - // scimUrl is not saved to storage - }); + })); this.baseUrl = urls.base; this.webVaultUrl = urls.webVault; @@ -287,8 +321,8 @@ export class EnvironmentService implements EnvironmentServiceAbstraction { ); } - async getHost(userId?: string) { - const region = await this.getRegion(userId ? userId : null); + async getHost(userId?: UserId) { + const region = await this.getRegion(userId); switch (region) { case Region.US: @@ -297,21 +331,30 @@ export class EnvironmentService implements EnvironmentServiceAbstraction { return RegionDomain.EU; default: { // Environment is self-hosted - const envUrls = await this.stateService.getEnvironmentUrls( - userId ? { userId: userId } : null, - ); + const envUrls = await this.getEnvironmentUrls(userId); return Utils.getHost(envUrls.webVault || envUrls.base); } } } - private async getRegion(userId?: string) { - return this.stateService.getRegion(userId ? { userId: userId } : null); + private async getRegion(userId: UserId | null) { + // Previous rules dictated that we only get from user scoped state if there is an active user. + const activeUserId = await firstValueFrom(this.activeAccountId$); + return activeUserId == null + ? await firstValueFrom(this.regionGlobalState.state$) + : await firstValueFrom(this.stateProvider.getUser(userId ?? activeUserId, REGION_KEY).state$); + } + + private async getEnvironmentUrls(userId: UserId | null) { + return userId == null + ? (await firstValueFrom(this.urlsGlobalState.state$)) ?? new EnvironmentUrls() + : (await firstValueFrom(this.stateProvider.getUser(userId, URLS_KEY).state$)) ?? + new EnvironmentUrls(); } async setRegion(region: Region) { this.selectedRegion = region; - await this.stateService.setRegion(region); + await this.regionGlobalState.update(() => region); if (region === Region.SelfHosted) { // If user saves a self-hosted region with empty fields, default to US @@ -320,7 +363,7 @@ export class EnvironmentService implements EnvironmentServiceAbstraction { } } else { // If we are setting the region to EU or US, clear the self-hosted URLs - await this.stateService.setEnvironmentUrls(new EnvironmentUrls()); + await this.urlsGlobalState.update(() => new EnvironmentUrls()); if (region === Region.EU) { this.setUrlsInternal(this.euUrls); } else if (region === Region.US) { @@ -329,6 +372,13 @@ export class EnvironmentService implements EnvironmentServiceAbstraction { } } + async seedUserEnvironment(userId: UserId) { + const globalRegion = await firstValueFrom(this.regionGlobalState.state$); + const globalUrls = await firstValueFrom(this.urlsGlobalState.state$); + await this.stateProvider.getUser(userId, REGION_KEY).update(() => globalRegion); + await this.stateProvider.getUser(userId, URLS_KEY).update(() => globalUrls); + } + private setUrlsInternal(urls: Urls) { this.baseUrl = this.formatUrl(urls.base); this.webVaultUrl = this.formatUrl(urls.webVault); diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 2ed0d606b06..e4f162fddbf 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -9,7 +9,6 @@ import { Policy } from "../../admin-console/models/domain/policy"; import { AccountService } from "../../auth/abstractions/account.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; -import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; @@ -32,6 +31,7 @@ import { LocalData } from "../../vault/models/data/local.data"; 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 { EnvironmentService } from "../abstractions/environment.service"; import { LogService } from "../abstractions/log.service"; import { StateService as StateServiceAbstraction } from "../abstractions/state.service"; import { @@ -105,6 +105,7 @@ export class StateService< protected logService: LogService, protected stateFactory: StateFactory, protected accountService: AccountService, + protected environmentService: EnvironmentService, protected useAccountCache: boolean = true, ) { // If the account gets changed, verify the new account is unlocked @@ -215,7 +216,7 @@ export class StateService< } async addAccount(account: TAccount) { - account = await this.setAccountEnvironment(account); + await this.environmentService.seedUserEnvironment(account.profile.userId as UserId); await this.updateState(async (state) => { state.authenticatedAccounts.push(account.profile.userId); await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts); @@ -1983,49 +1984,6 @@ export class StateService< ); } - async getEnvironmentUrls(options?: StorageOptions): Promise { - if ((await this.state())?.activeUserId == null) { - return await this.getGlobalEnvironmentUrls(options); - } - options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); - return (await this.getAccount(options))?.settings?.environmentUrls ?? new EnvironmentUrls(); - } - - async setEnvironmentUrls(value: EnvironmentUrls, options?: StorageOptions): Promise { - // Global values are set on each change and the current global settings are passed to any newly authed accounts. - // This is to allow setting environment values before an account is active, while still allowing individual accounts to have their own environments. - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.environmentUrls = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - - async getRegion(options?: StorageOptions): Promise { - if ((await this.state())?.activeUserId == null) { - options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); - return (await this.getGlobals(options)).region ?? null; - } - options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); - return (await this.getAccount(options))?.settings?.region ?? null; - } - - async setRegion(value: string, options?: StorageOptions): Promise { - // Global values are set on each change and the current global settings are passed to any newly authed accounts. - // This is to allow setting region values before an account is active, while still allowing individual accounts to have their own region. - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.region = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getEquivalentDomains(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) @@ -3032,17 +2990,12 @@ export class StateService< await this.defaultOnDiskLocalOptions(), ), ); - // EnvironmentUrls and region are set before authenticating and should override whatever is stored from any previous session - const environmentUrls = account.settings.environmentUrls; - const region = account.settings.region; if (storedAccount?.settings != null) { account.settings = storedAccount.settings; } else if (await this.storageService.has(keys.tempAccountSettings)) { account.settings = await this.storageService.get(keys.tempAccountSettings); await this.storageService.remove(keys.tempAccountSettings); } - account.settings.environmentUrls = environmentUrls; - account.settings.region = region; if ( account.settings.vaultTimeoutAction === VaultTimeoutAction.LogOut && @@ -3070,8 +3023,6 @@ export class StateService< ), ); if (storedAccount?.settings != null) { - storedAccount.settings.environmentUrls = account.settings.environmentUrls; - storedAccount.settings.region = account.settings.region; account.settings = storedAccount.settings; } await this.storageService.save( @@ -3093,8 +3044,6 @@ export class StateService< this.reconcileOptions({ userId: account.profile.userId }, await this.defaultOnDiskOptions()), ); if (storedAccount?.settings != null) { - storedAccount.settings.environmentUrls = account.settings.environmentUrls; - storedAccount.settings.region = account.settings.region; account.settings = storedAccount.settings; } await this.storageService.save( @@ -3237,23 +3186,6 @@ export class StateService< return Object.assign(this.createAccount(), persistentAccountInformation); } - // The environment urls and region are selected before login and are transferred here to an authenticated account - protected async setAccountEnvironment(account: TAccount): Promise { - account.settings.region = await this.getGlobalRegion(); - account.settings.environmentUrls = await this.getGlobalEnvironmentUrls(); - return account; - } - - protected async getGlobalEnvironmentUrls(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); - return (await this.getGlobals(options)).environmentUrls ?? new EnvironmentUrls(); - } - - protected async getGlobalRegion(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); - return (await this.getGlobals(options)).region ?? null; - } - protected async clearDecryptedDataForActiveUser(): Promise { await this.updateState(async (state) => { const userId = state?.activeUserId; diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 898ac5c2f9e..e007174aa1c 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -22,6 +22,7 @@ export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); export const BILLING_BANNERS_DISK = new StateDefinition("billingBanners", "disk"); export const CRYPTO_DISK = new StateDefinition("crypto", "disk"); +export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk"); export const GENERATOR_DISK = new StateDefinition("generator", "disk"); export const GENERATOR_MEMORY = new StateDefinition("generator", "memory"); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 08a505adcf4..4f8597608e0 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -7,6 +7,7 @@ import { MigrationBuilder } from "./migration-builder"; import { MigrationHelper } from "./migration-helper"; import { EverHadUserKeyMigrator } from "./migrations/10-move-ever-had-user-key-to-state-providers"; import { OrganizationKeyMigrator } from "./migrations/11-move-org-keys-to-state-providers"; +import { MoveEnvironmentStateToProviders } from "./migrations/12-move-environment-state-to-providers"; 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"; @@ -17,7 +18,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 2; -export const CURRENT_VERSION = 11; +export const CURRENT_VERSION = 12; export type MinVersion = typeof MIN_VERSION; export async function migrate( @@ -44,7 +45,8 @@ export async function migrate( .with(MoveStateVersionMigrator, 7, 8) .with(MoveBrowserSettingsToGlobal, 8, 9) .with(EverHadUserKeyMigrator, 9, 10) - .with(OrganizationKeyMigrator, 10, CURRENT_VERSION) + .with(OrganizationKeyMigrator, 10, 11) + .with(MoveEnvironmentStateToProviders, 11, CURRENT_VERSION) .migrate(migrationHelper); } diff --git a/libs/common/src/state-migrations/migration-helper.spec.ts b/libs/common/src/state-migrations/migration-helper.spec.ts index 5c24d700ac3..5736bd7e6c2 100644 --- a/libs/common/src/state-migrations/migration-helper.spec.ts +++ b/libs/common/src/state-migrations/migration-helper.spec.ts @@ -1,11 +1,16 @@ import { MockProxy, mock } from "jest-mock-extended"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages +import { FakeStorageService } from "../../spec/fake-storage.service"; // 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"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to generate unique strings for injection +import { Utils } from "../platform/misc/utils"; import { MigrationHelper } from "./migration-helper"; +import { Migrator } from "./migrator"; const exampleJSON = { authenticatedAccounts: [ @@ -172,3 +177,129 @@ export function mockMigrationHelper( mockHelper.getAccounts.mockImplementation(() => helper.getAccounts()); return mockHelper; } + +// TODO: Use const generic for TUsers in TypeScript 5.0 so consumers don't have to `as const` themselves +export type InitialDataHint = { + /** + * A string array of the users id who are authenticated + * + * NOTE: It's recommended to as const this string array so you get type help defining the users data + */ + authenticatedAccounts?: TUsers; + /** + * Global data + */ + global?: unknown; + /** + * Other top level data + */ + [key: string]: unknown; +} & { + /** + * A users data + */ + [userData in TUsers[number]]?: unknown; +}; + +type InjectedData = { + propertyName: string; + propertyValue: string; + originalPath: string[]; +}; + +// This is a slight lie, technically the type is `Record +// but for the purposes of things in the migrations this is enough. +function isStringRecord(object: unknown | undefined): object is Record { + return object && typeof object === "object" && !Array.isArray(object); +} + +function injectData(data: Record, path: string[]): InjectedData[] { + if (!data) { + return []; + } + + const injectedData: InjectedData[] = []; + + // Traverse keys for other objects + const keys = Object.keys(data); + for (const key of keys) { + const currentProperty = data[key]; + if (isStringRecord(currentProperty)) { + injectedData.push(...injectData(currentProperty, [...path, key])); + } + } + + const propertyName = `__injectedProperty__${Utils.newGuid()}`; + const propertyValue = `__injectedValue__${Utils.newGuid()}`; + + injectedData.push({ + propertyName: propertyName, + propertyValue: propertyValue, + // Track the path it was originally injected in just for a better error + originalPath: path, + }); + data[propertyName] = propertyValue; + return injectedData; +} + +function expectInjectedData( + data: Record, + injectedData: InjectedData[], +): [data: Record, leftoverInjectedData: InjectedData[]] { + const keys = Object.keys(data); + for (const key of keys) { + const propertyValue = data[key]; + // Injected data does not have to be found exactly where it was injected, + // just that it exists at all. + const injectedIndex = injectedData.findIndex( + (d) => + d.propertyName === key && + typeof propertyValue === "string" && + propertyValue === d.propertyValue, + ); + + if (injectedIndex !== -1) { + // We found something we injected, remove it + injectedData.splice(injectedIndex, 1); + delete data[key]; + continue; + } + + if (isStringRecord(propertyValue)) { + const [updatedData, leftoverInjectedData] = expectInjectedData(propertyValue, injectedData); + data[key] = updatedData; + injectedData = leftoverInjectedData; + } + } + + return [data, injectedData]; +} + +/** + * Runs the {@link Migrator.migrate} method of your migrator. You may pass in your test data and get back the data after the migration. + * This also injects extra properties at every level of your state and makes sure that it can be found. + * @param migrator Your migrator to use to do the migration + * @param initalData The data to start with + * @returns State after your migration has ran. + */ +// TODO: Use const generic for TUsers in TypeScript 5.0 so consumers don't have to `as const` themselves +export async function runMigrator< + TMigrator extends Migrator, + TUsers extends readonly string[] = string[], +>(migrator: TMigrator, initalData?: InitialDataHint): Promise> { + // Inject fake data at every level of the object + const allInjectedData = injectData(initalData, []); + + const fakeStorageService = new FakeStorageService(initalData); + const helper = new MigrationHelper(migrator.fromVersion, fakeStorageService, mock()); + + // Run their migrations + await migrator.migrate(helper); + const [data, leftoverInjectedData] = expectInjectedData( + fakeStorageService.internalStore, + allInjectedData, + ); + expect(leftoverInjectedData).toHaveLength(0); + + return data; +} diff --git a/libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.spec.ts b/libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.spec.ts new file mode 100644 index 00000000000..33736fcb5d8 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.spec.ts @@ -0,0 +1,157 @@ +import { runMigrator } from "../migration-helper.spec"; + +import { MoveEnvironmentStateToProviders } from "./12-move-environment-state-to-providers"; + +describe("MoveEnvironmentStateToProviders", () => { + const migrator = new MoveEnvironmentStateToProviders(11, 12); + + it("can migrate all data", async () => { + const output = await runMigrator(migrator, { + authenticatedAccounts: ["user1", "user2"] as const, + global: { + region: "US", + environmentUrls: { + base: "example.com", + }, + extra: "data", + }, + user1: { + extra: "data", + settings: { + extra: "data", + region: "US", + environmentUrls: { + base: "example.com", + }, + }, + }, + user2: { + extra: "data", + settings: { + region: "EU", + environmentUrls: { + base: "other.example.com", + }, + extra: "data", + }, + }, + extra: "data", + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user1", "user2"], + global: { + extra: "data", + }, + global_environment_region: "US", + global_environment_urls: { + base: "example.com", + }, + user1: { + extra: "data", + settings: { + extra: "data", + }, + }, + user2: { + extra: "data", + settings: { + extra: "data", + }, + }, + extra: "data", + user_user1_environment_region: "US", + user_user2_environment_region: "EU", + user_user1_environment_urls: { + base: "example.com", + }, + user_user2_environment_urls: { + base: "other.example.com", + }, + }); + }); + + it("handles missing parts", async () => { + const output = await runMigrator(migrator, { + authenticatedAccounts: ["user1", "user2"], + global: { + extra: "data", + }, + user1: { + extra: "data", + settings: { + extra: "data", + }, + }, + user2: null, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user1", "user2"], + global: { + extra: "data", + }, + user1: { + extra: "data", + settings: { + extra: "data", + }, + }, + user2: null, + }); + }); + + it("can migrate only global data", async () => { + const output = await runMigrator(migrator, { + authenticatedAccounts: [] as const, + global: { + region: "Self-Hosted", + }, + }); + + expect(output).toEqual({ + authenticatedAccounts: [], + global_environment_region: "Self-Hosted", + global: {}, + }); + }); + + it("can migrate only user state", async () => { + const output = await runMigrator(migrator, { + authenticatedAccounts: ["user1"] as const, + global: null, + user1: { + settings: { + region: "Self-Hosted", + environmentUrls: { + base: "some-base-url", + api: "some-api-url", + identity: "some-identity-url", + icons: "some-icons-url", + notifications: "some-notifications-url", + events: "some-events-url", + webVault: "some-webVault-url", + keyConnector: "some-keyConnector-url", + }, + }, + }, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user1"] as const, + global: null, + user1: { settings: {} }, + user_user1_environment_region: "Self-Hosted", + user_user1_environment_urls: { + base: "some-base-url", + api: "some-api-url", + identity: "some-identity-url", + icons: "some-icons-url", + notifications: "some-notifications-url", + events: "some-events-url", + webVault: "some-webVault-url", + keyConnector: "some-keyConnector-url", + }, + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.ts b/libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.ts new file mode 100644 index 00000000000..c95e726bfee --- /dev/null +++ b/libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.ts @@ -0,0 +1,132 @@ +import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type EnvironmentUrls = Record; + +type ExpectedAccountType = { + settings?: { region?: string; environmentUrls?: EnvironmentUrls }; +}; + +type ExpectedGlobalType = { region?: string; environmentUrls?: EnvironmentUrls }; + +const ENVIRONMENT_STATE: StateDefinitionLike = { name: "environment" }; + +const REGION_KEY: KeyDefinitionLike = { key: "region", stateDefinition: ENVIRONMENT_STATE }; +const URLS_KEY: KeyDefinitionLike = { key: "urls", stateDefinition: ENVIRONMENT_STATE }; + +export class MoveEnvironmentStateToProviders extends Migrator<11, 12> { + async migrate(helper: MigrationHelper): Promise { + const legacyGlobal = await helper.get("global"); + + // Move global data + if (legacyGlobal?.region != null) { + await helper.setToGlobal(REGION_KEY, legacyGlobal.region); + } + + if (legacyGlobal?.environmentUrls != null) { + await helper.setToGlobal(URLS_KEY, legacyGlobal.environmentUrls); + } + + const legacyAccounts = await helper.getAccounts(); + + await Promise.all( + legacyAccounts.map(async ({ userId, account }) => { + // Move account data + if (account?.settings?.region != null) { + await helper.setToUser(userId, REGION_KEY, account.settings.region); + } + + if (account?.settings?.environmentUrls != null) { + await helper.setToUser(userId, URLS_KEY, account.settings.environmentUrls); + } + + // Delete old account data + delete account?.settings?.region; + delete account?.settings?.environmentUrls; + await helper.set(userId, account); + }), + ); + + // Delete legacy global data + delete legacyGlobal?.region; + delete legacyGlobal?.environmentUrls; + await helper.set("global", legacyGlobal); + } + + async rollback(helper: MigrationHelper): Promise { + let legacyGlobal = await helper.get("global"); + + let updatedLegacyGlobal = false; + + const globalRegion = await helper.getFromGlobal(REGION_KEY); + + if (globalRegion) { + if (!legacyGlobal) { + legacyGlobal = {}; + } + + updatedLegacyGlobal = true; + legacyGlobal.region = globalRegion; + await helper.setToGlobal(REGION_KEY, null); + } + + const globalUrls = await helper.getFromGlobal(URLS_KEY); + + if (globalUrls) { + if (!legacyGlobal) { + legacyGlobal = {}; + } + + updatedLegacyGlobal = true; + legacyGlobal.environmentUrls = globalUrls; + await helper.setToGlobal(URLS_KEY, null); + } + + if (updatedLegacyGlobal) { + await helper.set("global", legacyGlobal); + } + + async function rollbackUser(userId: string, account: ExpectedAccountType) { + let updatedAccount = false; + const userRegion = await helper.getFromUser(userId, REGION_KEY); + + if (userRegion) { + if (!account) { + account = {}; + } + + if (!account.settings) { + account.settings = {}; + } + + updatedAccount = true; + account.settings.region = userRegion; + await helper.setToUser(userId, REGION_KEY, null); + } + + const userUrls = await helper.getFromUser(userId, URLS_KEY); + + if (userUrls) { + if (!account) { + account = {}; + } + + if (!account.settings) { + account.settings = {}; + } + + updatedAccount = true; + account.settings.environmentUrls = userUrls; + await helper.setToUser(userId, URLS_KEY, null); + } + + if (updatedAccount) { + await helper.set(userId, account); + } + } + + const accounts = await helper.getAccounts(); + + await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account))); + } +} diff --git a/libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts b/libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts index f5a91886522..07aa2463765 100644 --- a/libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts +++ b/libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts @@ -1,31 +1,14 @@ -import { mock } from "jest-mock-extended"; - -import { FakeStorageService } from "../../../spec/fake-storage.service"; -import { MigrationHelper } from "../migration-helper"; -import { Migrator } from "../migrator"; +import { runMigrator } from "../migration-helper.spec"; import { MoveBrowserSettingsToGlobal } from "./9-move-browser-settings-to-global"; -type TestState = { authenticatedAccounts: string[] } & { [key: string]: unknown }; - -// This could become a helper available to anyone -const runMigrator = async >( - migrator: TMigrator, - initalData?: Record, -): Promise> => { - const fakeStorageService = new FakeStorageService(initalData); - const helper = new MigrationHelper(migrator.fromVersion, fakeStorageService, mock()); - await migrator.migrate(helper); - return fakeStorageService.internalStore; -}; - describe("MoveBrowserSettingsToGlobal", () => { const myMigrator = new MoveBrowserSettingsToGlobal(8, 9); // This could be the state for a browser client who has never touched the settings or this could // be a different client who doesn't make it possible to toggle these settings it("doesn't set any value to global if there is no equivalent settings on the account", async () => { - const testInput: TestState = { + const output = await runMigrator(myMigrator, { authenticatedAccounts: ["user1"], global: { theme: "system", // A real global setting that should persist after migration @@ -35,9 +18,7 @@ describe("MoveBrowserSettingsToGlobal", () => { region: "Self-hosted", }, }, - }; - - const output = await runMigrator(myMigrator, testInput); + }); // No additions to the global state expect(output["global"]).toEqual({ @@ -55,7 +36,7 @@ describe("MoveBrowserSettingsToGlobal", () => { // This could be a user who opened up the settings page and toggled the checkbox, since this setting infers undefined // as false this is essentially the default value. it("sets the setting from the users settings if they have toggled the setting but placed it back to it's inferred", async () => { - const testInput: TestState = { + const output = await runMigrator(myMigrator, { authenticatedAccounts: ["user1"], global: { theme: "system", // A real global setting that should persist after migration @@ -71,9 +52,7 @@ describe("MoveBrowserSettingsToGlobal", () => { region: "Self-hosted", }, }, - }; - - const output = await runMigrator(myMigrator, testInput); + }); // User settings should have moved to global expect(output["global"]).toEqual({ @@ -94,7 +73,7 @@ describe("MoveBrowserSettingsToGlobal", () => { // The user has set a value and it's not the default, we should respect that choice globally it("should take the only users settings", async () => { - const testInput: TestState = { + const output = await runMigrator(myMigrator, { authenticatedAccounts: ["user1"], global: { theme: "system", // A real global setting that should persist after migration @@ -110,9 +89,7 @@ describe("MoveBrowserSettingsToGlobal", () => { region: "Self-hosted", }, }, - }; - - const output = await runMigrator(myMigrator, testInput); + }); // The value for the single user value should be set to global expect(output["global"]).toEqual({ @@ -134,7 +111,7 @@ describe("MoveBrowserSettingsToGlobal", () => { // but in the bizzare case, we should interpret any user having the feature turned on as the value for // all the accounts. it("should take the false value if there are conflicting choices", async () => { - const testInput: TestState = { + const output = await runMigrator(myMigrator, { authenticatedAccounts: ["user1", "user2"], global: { theme: "system", // A real global setting that should persist after migration @@ -161,9 +138,7 @@ describe("MoveBrowserSettingsToGlobal", () => { region: "Self-hosted", }, }, - }; - - const output = await runMigrator(myMigrator, testInput); + }); // The false settings should be respected over the true values // neverDomains should be combined into a single object @@ -191,7 +166,7 @@ describe("MoveBrowserSettingsToGlobal", () => { // if one user has toggled the setting back to on and one user has never touched the setting, // persist the false value into the global state. it("should persist the false value if one user has that in their settings", async () => { - const testInput: TestState = { + const output = await runMigrator(myMigrator, { authenticatedAccounts: ["user1", "user2"], global: { theme: "system", // A real global setting that should persist after migration @@ -212,9 +187,7 @@ describe("MoveBrowserSettingsToGlobal", () => { region: "Self-hosted", }, }, - }; - - const output = await runMigrator(myMigrator, testInput); + }); // The false settings should be respected over the true values // neverDomains should be combined into a single object @@ -241,7 +214,7 @@ describe("MoveBrowserSettingsToGlobal", () => { // if one user has toggled the setting off and one user has never touched the setting, // persist the false value into the global state. it("should persist the false value from a user with no settings since undefined is inferred as false", async () => { - const testInput: TestState = { + const output = await runMigrator(myMigrator, { authenticatedAccounts: ["user1", "user2"], global: { theme: "system", // A real global setting that should persist after migration @@ -262,9 +235,7 @@ describe("MoveBrowserSettingsToGlobal", () => { region: "Self-hosted", }, }, - }; - - const output = await runMigrator(myMigrator, testInput); + }); // The false settings should be respected over the true values // neverDomains should be combined into a single object @@ -292,7 +263,7 @@ describe("MoveBrowserSettingsToGlobal", () => { // id of the non-current account isn't saved to the authenticatedAccounts array so we don't have a great way to // get the state and include it in our calculations for what the global state should be. it("only cares about users defined in authenticatedAccounts", async () => { - const testInput: TestState = { + const output = await runMigrator(myMigrator, { authenticatedAccounts: ["user1"], global: { theme: "system", // A real global setting that should persist after migration @@ -319,9 +290,7 @@ describe("MoveBrowserSettingsToGlobal", () => { region: "Self-hosted", }, }, - }; - - const output = await runMigrator(myMigrator, testInput); + }); // The true settings should be respected over the false values because that whole users values // shouldn't be respected.