1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[PM-5535] Migrate Environment Service to StateProvider (#7621)

* Migrate EnvironmentService

* Move Migration Test Helper

* Claim StateDefinition

* Add State Migration

* Update StateServices

* Update EnvironmentService Abstraction

* Update DI

* Update Browser Instantiation

* Fix BrowserEnvironmentService

* Update Desktop & CLI Instantiation

* Update Usage

* Create isStringRecord helper

* Fix Old Tests

* Use Existing AccountService

* Don't Rely on Parameter Mutation

* Fix Conflicts
This commit is contained in:
Justin Baur
2024-01-24 14:21:50 -05:00
committed by GitHub
parent 842fa5153b
commit c1d5351075
26 changed files with 648 additions and 232 deletions

View File

@@ -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<void>;
getSendUrl: () => string;
getIconsUrl: () => string;
getApiUrl: () => string;

View File

@@ -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<T extends Account = Account> {
setEntityId: (value: string, options?: StorageOptions) => Promise<void>;
getEntityType: (options?: StorageOptions) => Promise<any>;
setEntityType: (value: string, options?: StorageOptions) => Promise<void>;
getEnvironmentUrls: (options?: StorageOptions) => Promise<EnvironmentUrls>;
setEnvironmentUrls: (value: EnvironmentUrls, options?: StorageOptions) => Promise<void>;
getRegion: (options?: StorageOptions) => Promise<string>;
setRegion: (value: string, options?: StorageOptions) => Promise<void>;
getEquivalentDomains: (options?: StorageOptions) => Promise<string[][]>;
setEquivalentDomains: (value: string, options?: StorageOptions) => Promise<void>;
getEventCollection: (options?: StorageOptions) => Promise<EventData[]>;

View File

@@ -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<string, Record<string, boolean>>;
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<string, EncString>(
obj?.pinProtected,
EncString.fromJSON,

View File

@@ -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;

View File

@@ -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>(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();

View File

@@ -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<Region>(ENVIRONMENT_DISK, "region", {
deserializer: (s) => s,
});
const URLS_KEY = new KeyDefinition<EnvironmentUrls>(ENVIRONMENT_DISK, "urls", {
deserializer: EnvironmentUrls.fromJSON,
});
export class EnvironmentService implements EnvironmentServiceAbstraction {
private readonly urlsSubject = new ReplaySubject<void>(1);
@@ -27,6 +44,11 @@ export class EnvironmentService implements EnvironmentServiceAbstraction {
private scimUrl: string = null;
private cloudWebVaultUrl: string;
private regionGlobalState: GlobalState<Region | null>;
private urlsGlobalState: GlobalState<EnvironmentUrls | null>;
private activeAccountId$: Observable<UserId | null>;
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<void> {
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);

View File

@@ -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<TGlobalState, TAccount>,
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<EnvironmentUrls> {
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<void> {
// 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<string> {
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<void> {
// 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<string[][]> {
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<AccountSettings>(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<TAccount> {
account.settings.region = await this.getGlobalRegion();
account.settings.environmentUrls = await this.getGlobalEnvironmentUrls();
return account;
}
protected async getGlobalEnvironmentUrls(options?: StorageOptions): Promise<EnvironmentUrls> {
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
return (await this.getGlobals(options)).environmentUrls ?? new EnvironmentUrls();
}
protected async getGlobalRegion(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
return (await this.getGlobals(options)).region ?? null;
}
protected async clearDecryptedDataForActiveUser(): Promise<void> {
await this.updateState(async (state) => {
const userId = state?.activeUserId;

View File

@@ -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");

View File

@@ -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);
}

View File

@@ -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<TUsers extends readonly string[]> = {
/**
* 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<string | symbol, unknown>
// but for the purposes of things in the migrations this is enough.
function isStringRecord(object: unknown | undefined): object is Record<string, unknown> {
return object && typeof object === "object" && !Array.isArray(object);
}
function injectData(data: Record<string, unknown>, 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<string, unknown>,
injectedData: InjectedData[],
): [data: Record<string, unknown>, 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<number, number>,
TUsers extends readonly string[] = string[],
>(migrator: TMigrator, initalData?: InitialDataHint<TUsers>): Promise<Record<string, unknown>> {
// 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;
}

View File

@@ -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",
},
});
});
});

View File

@@ -0,0 +1,132 @@
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { Migrator } from "../migrator";
type EnvironmentUrls = Record<string, string>;
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<void> {
const legacyGlobal = await helper.get<ExpectedGlobalType>("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<ExpectedAccountType>();
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<void> {
let legacyGlobal = await helper.get<ExpectedGlobalType>("global");
let updatedLegacyGlobal = false;
const globalRegion = await helper.getFromGlobal<string>(REGION_KEY);
if (globalRegion) {
if (!legacyGlobal) {
legacyGlobal = {};
}
updatedLegacyGlobal = true;
legacyGlobal.region = globalRegion;
await helper.setToGlobal(REGION_KEY, null);
}
const globalUrls = await helper.getFromGlobal<EnvironmentUrls>(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<string>(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<EnvironmentUrls>(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<ExpectedAccountType>();
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
}
}

View File

@@ -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 <TMigrator extends Migrator<number, number>>(
migrator: TMigrator,
initalData?: Record<string, unknown>,
): Promise<Record<string, unknown>> => {
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.