mirror of
https://github.com/bitwarden/browser
synced 2025-12-17 16:53:34 +00:00
Merge branch 'master' into ps/PM-2841/remove-swal
This commit is contained in:
@@ -15,6 +15,14 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
interface VaultTimeoutFormValue {
|
||||
vaultTimeout: number | null;
|
||||
custom: {
|
||||
hours: number | null;
|
||||
minutes: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
@Directive()
|
||||
export class VaultTimeoutInputComponent
|
||||
implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges
|
||||
@@ -70,26 +78,38 @@ export class VaultTimeoutInputComponent
|
||||
this.applyVaultTimeoutPolicy();
|
||||
});
|
||||
|
||||
this.form.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => {
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getVaultTimeout(value));
|
||||
}
|
||||
});
|
||||
this.form.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((value: VaultTimeoutFormValue) => {
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getVaultTimeout(value));
|
||||
}
|
||||
});
|
||||
|
||||
// Assign the previous value to the custom fields
|
||||
// Assign the current value to the custom fields
|
||||
// so that if the user goes from a numeric value to custom
|
||||
// we can initialize the custom fields with the current value
|
||||
// ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields
|
||||
this.form.controls.vaultTimeout.valueChanges
|
||||
.pipe(
|
||||
filter((value) => value !== VaultTimeoutInputComponent.CUSTOM_VALUE),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((_) => {
|
||||
const current = Math.max(this.form.value.vaultTimeout, 0);
|
||||
this.form.patchValue({
|
||||
custom: {
|
||||
hours: Math.floor(current / 60),
|
||||
minutes: current % 60,
|
||||
.subscribe((value) => {
|
||||
const current = Math.max(value, 0);
|
||||
|
||||
// This cannot emit an event b/c it would cause form.valueChanges to fire again
|
||||
// and we are already handling that above so just silently update
|
||||
// custom fields when vaultTimeout changes to a non-custom value
|
||||
this.form.patchValue(
|
||||
{
|
||||
custom: {
|
||||
hours: Math.floor(current / 60),
|
||||
minutes: current % 60,
|
||||
},
|
||||
},
|
||||
});
|
||||
{ emitEvent: false }
|
||||
);
|
||||
});
|
||||
|
||||
this.canLockVault$ = this.vaultTimeoutSettingsService
|
||||
@@ -113,7 +133,7 @@ export class VaultTimeoutInputComponent
|
||||
}
|
||||
}
|
||||
|
||||
getVaultTimeout(value: any) {
|
||||
getVaultTimeout(value: VaultTimeoutFormValue) {
|
||||
if (value.vaultTimeout !== VaultTimeoutInputComponent.CUSTOM_VALUE) {
|
||||
return value.vaultTimeout;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Directive, ElementRef, HostListener, Input } from "@angular/core";
|
||||
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
@Directive({
|
||||
@@ -15,6 +16,9 @@ export class CopyTextDirective {
|
||||
return;
|
||||
}
|
||||
|
||||
this.platformUtilsService.copyToClipboard(this.copyText, { window: window });
|
||||
const timeout = this.platformUtilsService.getClientType() === ClientType.Desktop ? 100 : 0;
|
||||
setTimeout(() => {
|
||||
this.platformUtilsService.copyToClipboard(this.copyText, { window: window });
|
||||
}, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,6 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/platform/abstractions/state-migration.service";
|
||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
@@ -94,7 +93,6 @@ import { EncryptServiceImplementation } from "@bitwarden/common/platform/service
|
||||
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
|
||||
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
|
||||
import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service";
|
||||
import { StateService } from "@bitwarden/common/platform/services/state.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
|
||||
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
||||
@@ -480,16 +478,10 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
|
||||
SECURE_STORAGE,
|
||||
MEMORY_STORAGE,
|
||||
LogService,
|
||||
StateMigrationServiceAbstraction,
|
||||
STATE_FACTORY,
|
||||
STATE_SERVICE_USE_CACHE,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: StateMigrationServiceAbstraction,
|
||||
useClass: StateMigrationService,
|
||||
deps: [AbstractStorageService, SECURE_STORAGE, STATE_FACTORY],
|
||||
},
|
||||
{
|
||||
provide: VaultExportServiceAbstraction,
|
||||
useClass: VaultExportService,
|
||||
|
||||
@@ -41,10 +41,7 @@ import { IdentityCaptchaResponse } from "../models/response/identity-captcha.res
|
||||
import { IdentityTokenResponse } from "../models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
|
||||
import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response";
|
||||
import {
|
||||
IUserDecryptionOptionsServerResponse,
|
||||
UserDecryptionOptionsResponse,
|
||||
} from "../models/response/user-decryption-options/user-decryption-options.response";
|
||||
import { IUserDecryptionOptionsServerResponse } from "../models/response/user-decryption-options/user-decryption-options.response";
|
||||
|
||||
import { PasswordLogInStrategy } from "./password-login.strategy";
|
||||
|
||||
@@ -65,10 +62,6 @@ const name = "NAME";
|
||||
const defaultUserDecryptionOptionsServerResponse: IUserDecryptionOptionsServerResponse = {
|
||||
HasMasterPassword: true,
|
||||
};
|
||||
const userDecryptionOptions = new UserDecryptionOptionsResponse(
|
||||
defaultUserDecryptionOptionsServerResponse
|
||||
);
|
||||
const acctDecryptionOptions = AccountDecryptionOptions.fromResponse(userDecryptionOptions);
|
||||
|
||||
const decodedToken = {
|
||||
sub: userId,
|
||||
@@ -197,7 +190,7 @@ describe("LogInStrategy", () => {
|
||||
},
|
||||
},
|
||||
keys: new AccountKeys(),
|
||||
decryptionOptions: acctDecryptionOptions,
|
||||
decryptionOptions: AccountDecryptionOptions.fromResponse(idTokenResponse),
|
||||
})
|
||||
);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
|
||||
|
||||
@@ -143,9 +143,7 @@ export abstract class LogInStrategy {
|
||||
},
|
||||
},
|
||||
keys: accountKeys,
|
||||
decryptionOptions: AccountDecryptionOptions.fromResponse(
|
||||
tokenResponse.userDecryptionOptions
|
||||
),
|
||||
decryptionOptions: AccountDecryptionOptions.fromResponse(tokenResponse),
|
||||
adminAuthRequest: adminAuthRequest?.toJSON(),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -266,7 +266,61 @@ describe("SsoLogInStrategy", () => {
|
||||
describe("Key Connector", () => {
|
||||
let tokenResponse: IdentityTokenResponse;
|
||||
beforeEach(() => {
|
||||
tokenResponse = identityTokenResponseFactory(null, { HasMasterPassword: false });
|
||||
tokenResponse = identityTokenResponseFactory(null, {
|
||||
HasMasterPassword: false,
|
||||
KeyConnectorOption: { KeyConnectorUrl: keyConnectorUrl },
|
||||
});
|
||||
tokenResponse.keyConnectorUrl = keyConnectorUrl;
|
||||
});
|
||||
|
||||
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {
|
||||
const masterKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).buffer as CsprngArray
|
||||
) as MasterKey;
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
cryptoService.getMasterKey.mockResolvedValue(masterKey);
|
||||
|
||||
await ssoLogInStrategy.logIn(credentials);
|
||||
|
||||
expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl);
|
||||
});
|
||||
|
||||
it("converts new SSO user with no master password to Key Connector on first login", async () => {
|
||||
tokenResponse.key = null;
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
|
||||
await ssoLogInStrategy.logIn(credentials);
|
||||
|
||||
expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(
|
||||
tokenResponse,
|
||||
ssoOrgId
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => {
|
||||
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
|
||||
const masterKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).buffer as CsprngArray
|
||||
) as MasterKey;
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
cryptoService.getMasterKey.mockResolvedValue(masterKey);
|
||||
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
|
||||
|
||||
await ssoLogInStrategy.logIn(credentials);
|
||||
|
||||
expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(masterKey);
|
||||
expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Key Connector Pre-TDE", () => {
|
||||
let tokenResponse: IdentityTokenResponse;
|
||||
beforeEach(() => {
|
||||
tokenResponse = identityTokenResponseFactory();
|
||||
tokenResponse.userDecryptionOptions = null;
|
||||
tokenResponse.keyConnectorUrl = keyConnectorUrl;
|
||||
});
|
||||
|
||||
|
||||
@@ -101,16 +101,22 @@ export class SsoLogInStrategy extends LogInStrategy {
|
||||
private shouldSetMasterKeyFromKeyConnector(tokenResponse: IdentityTokenResponse): boolean {
|
||||
const userDecryptionOptions = tokenResponse?.userDecryptionOptions;
|
||||
|
||||
// If the user has a master password, this means that they need to migrate to Key Connector, so we won't set the key here.
|
||||
// We default to false here because old server versions won't have hasMasterPassword and in that case we want to rely solely on the keyConnectorUrl.
|
||||
// TODO: remove null default after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
|
||||
const userHasMasterPassword = userDecryptionOptions?.hasMasterPassword ?? false;
|
||||
if (userDecryptionOptions != null) {
|
||||
const userHasMasterPassword = userDecryptionOptions.hasMasterPassword;
|
||||
const userHasKeyConnectorUrl =
|
||||
userDecryptionOptions.keyConnectorOption?.keyConnectorUrl != null;
|
||||
|
||||
const keyConnectorUrl = this.getKeyConnectorUrl(tokenResponse);
|
||||
|
||||
// In order for us to set the master key from Key Connector, we need to have a Key Connector URL
|
||||
// and the user must not have a master password.
|
||||
return keyConnectorUrl != null && !userHasMasterPassword;
|
||||
// In order for us to set the master key from Key Connector, we need to have a Key Connector URL
|
||||
// and the user must not have a master password.
|
||||
return userHasKeyConnectorUrl && !userHasMasterPassword;
|
||||
} else {
|
||||
// In pre-TDE versions of the server, the userDecryptionOptions will not be present.
|
||||
// In this case, we can determine if the user has a master password and has a Key Connector URL by
|
||||
// just checking the keyConnectorUrl property. This is because the server short-circuits on the response
|
||||
// and will not pass back the URL in the response if the user has a master password.
|
||||
// TODO: remove compatibility check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
|
||||
return tokenResponse.keyConnectorUrl != null;
|
||||
}
|
||||
}
|
||||
|
||||
private getKeyConnectorUrl(tokenResponse: IdentityTokenResponse): string {
|
||||
|
||||
@@ -55,7 +55,7 @@ export const TwoFactorProviders: Partial<Record<TwoFactorProviderType, TwoFactor
|
||||
description: null as string,
|
||||
priority: 4,
|
||||
sort: 5,
|
||||
premium: true,
|
||||
premium: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ export * from "./notification-type.enum";
|
||||
export * from "./product-type.enum";
|
||||
export * from "./provider-type.enum";
|
||||
export * from "./secure-note-type.enum";
|
||||
export * from "./state-version.enum";
|
||||
export * from "./storage-location.enum";
|
||||
export * from "./theme-type.enum";
|
||||
export * from "./uri-match-type.enum";
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export enum StateVersion {
|
||||
One = 1, // Original flat key/value pair store
|
||||
Two = 2, // Move to a typed State object
|
||||
Three = 3, // Fix migration of users' premium status
|
||||
Four = 4, // Fix 'Never Lock' option by removing stale data
|
||||
Five = 5, // Migrate to new storage of encrypted organization keys
|
||||
Six = 6, // Delete account.keys.legacyEtmKey property
|
||||
Seven = 7, // Remove global desktop auto prompt setting, move to account
|
||||
Latest = Seven,
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export class ReferenceEventRequest {
|
||||
id: string;
|
||||
session: string;
|
||||
layout: string;
|
||||
flow: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export abstract class StateMigrationService {
|
||||
needsMigration: () => Promise<boolean>;
|
||||
migrate: () => Promise<void>;
|
||||
}
|
||||
@@ -495,8 +495,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
|
||||
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getStateVersion: () => Promise<number>;
|
||||
setStateVersion: (value: number) => Promise<void>;
|
||||
getWindow: () => Promise<WindowState>;
|
||||
setWindow: (value: WindowState) => Promise<void>;
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@ import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls";
|
||||
import { ForceResetPasswordReason } from "../../../auth/models/domain/force-reset-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";
|
||||
import { UserDecryptionOptionsResponse } from "../../../auth/models/response/user-decryption-options/user-decryption-options.response";
|
||||
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
|
||||
import { KdfType, UriMatchType } from "../../../enums";
|
||||
import { EventData } from "../../../models/data/event.data";
|
||||
import { GeneratedPasswordHistory } from "../../../tools/generator/password";
|
||||
@@ -311,28 +311,46 @@ export class AccountDecryptionOptions {
|
||||
// return this.keyConnectorOption !== null && this.keyConnectorOption !== undefined;
|
||||
// }
|
||||
|
||||
static fromResponse(response: UserDecryptionOptionsResponse): AccountDecryptionOptions {
|
||||
static fromResponse(response: IdentityTokenResponse): AccountDecryptionOptions {
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accountDecryptionOptions = new AccountDecryptionOptions();
|
||||
accountDecryptionOptions.hasMasterPassword = response.hasMasterPassword;
|
||||
|
||||
if (response.trustedDeviceOption) {
|
||||
accountDecryptionOptions.trustedDeviceOption = new TrustedDeviceUserDecryptionOption(
|
||||
response.trustedDeviceOption.hasAdminApproval,
|
||||
response.trustedDeviceOption.hasLoginApprovingDevice,
|
||||
response.trustedDeviceOption.hasManageResetPasswordPermission
|
||||
);
|
||||
if (response.userDecryptionOptions) {
|
||||
// If the response has userDecryptionOptions, this means it's on a post-TDE server version and can interrogate
|
||||
// the new decryption options.
|
||||
const responseOptions = response.userDecryptionOptions;
|
||||
accountDecryptionOptions.hasMasterPassword = responseOptions.hasMasterPassword;
|
||||
|
||||
if (responseOptions.trustedDeviceOption) {
|
||||
accountDecryptionOptions.trustedDeviceOption = new TrustedDeviceUserDecryptionOption(
|
||||
responseOptions.trustedDeviceOption.hasAdminApproval,
|
||||
responseOptions.trustedDeviceOption.hasLoginApprovingDevice,
|
||||
responseOptions.trustedDeviceOption.hasManageResetPasswordPermission
|
||||
);
|
||||
}
|
||||
|
||||
if (responseOptions.keyConnectorOption) {
|
||||
accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption(
|
||||
responseOptions.keyConnectorOption.keyConnectorUrl
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If the response does not have userDecryptionOptions, this means it's on a pre-TDE server version and so
|
||||
// we must base our decryption options on the presence of the keyConnectorUrl.
|
||||
// Note that the presence of keyConnectorUrl implies that the user does not have a master password, as in pre-TDE
|
||||
// server versions, a master password short-circuited the addition of the keyConnectorUrl to the response.
|
||||
// TODO: remove this check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
|
||||
const usingKeyConnector = response.keyConnectorUrl != null;
|
||||
accountDecryptionOptions.hasMasterPassword = !usingKeyConnector;
|
||||
if (usingKeyConnector) {
|
||||
accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption(
|
||||
response.keyConnectorUrl
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.keyConnectorOption) {
|
||||
accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption(
|
||||
response.keyConnectorOption.keyConnectorUrl
|
||||
);
|
||||
}
|
||||
|
||||
return accountDecryptionOptions;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls";
|
||||
import { StateVersion, ThemeType } from "../../../enums";
|
||||
import { ThemeType } from "../../../enums";
|
||||
import { WindowState } from "../../../models/domain/window-state";
|
||||
|
||||
export class GlobalState {
|
||||
@@ -25,7 +25,6 @@ export class GlobalState {
|
||||
enableBiometrics?: boolean;
|
||||
biometricText?: string;
|
||||
noAutoPromptBiometricsText?: string;
|
||||
stateVersion: StateVersion = StateVersion.One;
|
||||
environmentUrls: EnvironmentUrls = new EnvironmentUrls();
|
||||
enableTray?: boolean;
|
||||
enableMinimizeToTray?: boolean;
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
import { MockProxy, any, mock } from "jest-mock-extended";
|
||||
|
||||
import { StateVersion } from "../../enums";
|
||||
import { AbstractStorageService } from "../abstractions/storage.service";
|
||||
import { StateFactory } from "../factories/state-factory";
|
||||
import { Account } from "../models/domain/account";
|
||||
import { GlobalState } from "../models/domain/global-state";
|
||||
|
||||
import { StateMigrationService } from "./state-migration.service";
|
||||
|
||||
const userId = "USER_ID";
|
||||
|
||||
// Note: each test calls the private migration method for that migration,
|
||||
// so that we don't accidentally run all following migrations as well
|
||||
|
||||
describe("State Migration Service", () => {
|
||||
let storageService: MockProxy<AbstractStorageService>;
|
||||
let secureStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let stateFactory: SubstituteOf<StateFactory>;
|
||||
|
||||
let stateMigrationService: StateMigrationService;
|
||||
|
||||
beforeEach(() => {
|
||||
storageService = mock();
|
||||
secureStorageService = Substitute.for<AbstractStorageService>();
|
||||
stateFactory = Substitute.for<StateFactory>();
|
||||
|
||||
stateMigrationService = new StateMigrationService(
|
||||
storageService,
|
||||
secureStorageService,
|
||||
stateFactory
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("StateVersion 3 to 4 migration", () => {
|
||||
beforeEach(() => {
|
||||
const globalVersion3: Partial<GlobalState> = {
|
||||
stateVersion: StateVersion.Three,
|
||||
};
|
||||
|
||||
storageService.get.calledWith("global", any()).mockResolvedValue(globalVersion3);
|
||||
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]);
|
||||
});
|
||||
|
||||
it("clears everBeenUnlocked", async () => {
|
||||
const accountVersion3: Account = {
|
||||
profile: {
|
||||
apiKeyClientId: null,
|
||||
convertAccountToKeyConnector: null,
|
||||
email: "EMAIL",
|
||||
emailVerified: true,
|
||||
everBeenUnlocked: true,
|
||||
hasPremiumPersonally: false,
|
||||
kdfIterations: 100000,
|
||||
kdfType: 0,
|
||||
keyHash: "KEY_HASH",
|
||||
lastSync: "LAST_SYNC",
|
||||
userId: userId,
|
||||
usesKeyConnector: false,
|
||||
forcePasswordResetReason: null,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedAccountVersion4: Account = {
|
||||
profile: {
|
||||
...accountVersion3.profile,
|
||||
},
|
||||
};
|
||||
delete expectedAccountVersion4.profile.everBeenUnlocked;
|
||||
|
||||
storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion3);
|
||||
|
||||
await (stateMigrationService as any).migrateStateFrom3To4();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledTimes(2);
|
||||
expect(storageService.save).toHaveBeenCalledWith(userId, expectedAccountVersion4, any());
|
||||
});
|
||||
|
||||
it("updates StateVersion number", async () => {
|
||||
await (stateMigrationService as any).migrateStateFrom3To4();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
"global",
|
||||
{ stateVersion: StateVersion.Four },
|
||||
any()
|
||||
);
|
||||
expect(storageService.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StateVersion 4 to 5 migration", () => {
|
||||
it("migrates organization keys to new format", async () => {
|
||||
const accountVersion4 = new Account({
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: "orgOneEncKey",
|
||||
orgTwoId: "orgTwoEncKey",
|
||||
orgThreeId: "orgThreeEncKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const expectedAccount = new Account({
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: {
|
||||
type: "organization",
|
||||
key: "orgOneEncKey",
|
||||
},
|
||||
orgTwoId: {
|
||||
type: "organization",
|
||||
key: "orgTwoEncKey",
|
||||
},
|
||||
orgThreeId: {
|
||||
type: "organization",
|
||||
key: "orgThreeEncKey",
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
} as any,
|
||||
});
|
||||
|
||||
const migratedAccount = await (stateMigrationService as any).migrateAccountFrom4To5(
|
||||
accountVersion4
|
||||
);
|
||||
|
||||
expect(migratedAccount).toEqual(expectedAccount);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StateVersion 5 to 6 migration", () => {
|
||||
it("deletes account.keys.legacyEtmKey value", async () => {
|
||||
const accountVersion5 = new Account({
|
||||
keys: {
|
||||
legacyEtmKey: "legacy key",
|
||||
},
|
||||
} as any);
|
||||
|
||||
const migratedAccount = await (stateMigrationService as any).migrateAccountFrom5To6(
|
||||
accountVersion5
|
||||
);
|
||||
|
||||
expect(migratedAccount.keys.legacyEtmKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("StateVersion 6 to 7 migration", () => {
|
||||
it("should delete global.noAutoPromptBiometrics value", async () => {
|
||||
storageService.get
|
||||
.calledWith("global", any())
|
||||
.mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true });
|
||||
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([]);
|
||||
|
||||
await stateMigrationService.migrate();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
"global",
|
||||
{
|
||||
stateVersion: StateVersion.Seven,
|
||||
},
|
||||
any()
|
||||
);
|
||||
});
|
||||
|
||||
it("should call migrateStateFrom6To7 on each account", async () => {
|
||||
const accountVersion6 = new Account({
|
||||
otherStuff: "other stuff",
|
||||
} as any);
|
||||
|
||||
storageService.get
|
||||
.calledWith("global", any())
|
||||
.mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true });
|
||||
storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]);
|
||||
storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion6);
|
||||
|
||||
const migrateSpy = jest.fn();
|
||||
(stateMigrationService as any).migrateAccountFrom6To7 = migrateSpy;
|
||||
|
||||
await stateMigrationService.migrate();
|
||||
|
||||
expect(migrateSpy).toHaveBeenCalledWith(true, accountVersion6);
|
||||
});
|
||||
|
||||
it("should update account.settings.disableAutoBiometricsPrompt value if global is no prompt", async () => {
|
||||
const result = await (stateMigrationService as any).migrateAccountFrom6To7(true, {
|
||||
otherStuff: "other stuff",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
otherStuff: "other stuff",
|
||||
settings: {
|
||||
disableAutoBiometricsPrompt: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update account.settings.disableAutoBiometricsPrompt value if global auto prompt is enabled", async () => {
|
||||
const result = await (stateMigrationService as any).migrateAccountFrom6To7(false, {
|
||||
otherStuff: "other stuff",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
otherStuff: "other stuff",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,587 +0,0 @@
|
||||
import { OrganizationData } from "../../admin-console/models/data/organization.data";
|
||||
import { PolicyData } from "../../admin-console/models/data/policy.data";
|
||||
import { ProviderData } from "../../admin-console/models/data/provider.data";
|
||||
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
|
||||
import { TokenService } from "../../auth/services/token.service";
|
||||
import { StateVersion, ThemeType, KdfType, HtmlStorageLocation } from "../../enums";
|
||||
import { EventData } from "../../models/data/event.data";
|
||||
import { GeneratedPasswordHistory } from "../../tools/generator/password";
|
||||
import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { CipherData } from "../../vault/models/data/cipher.data";
|
||||
import { CollectionData } from "../../vault/models/data/collection.data";
|
||||
import { FolderData } from "../../vault/models/data/folder.data";
|
||||
import { AbstractStorageService } from "../abstractions/storage.service";
|
||||
import { StateFactory } from "../factories/state-factory";
|
||||
import {
|
||||
Account,
|
||||
AccountSettings,
|
||||
EncryptionPair,
|
||||
AccountSettingsSettings,
|
||||
} from "../models/domain/account";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { GlobalState } from "../models/domain/global-state";
|
||||
import { StorageOptions } from "../models/domain/storage-options";
|
||||
|
||||
// Originally (before January 2022) storage was handled as a flat key/value pair store.
|
||||
// With the move to a typed object for state storage these keys should no longer be in use anywhere outside of this migration.
|
||||
const v1Keys: { [key: string]: string } = {
|
||||
accessToken: "accessToken",
|
||||
alwaysShowDock: "alwaysShowDock",
|
||||
autoConfirmFingerprints: "autoConfirmFingerprints",
|
||||
autoFillOnPageLoadDefault: "autoFillOnPageLoadDefault",
|
||||
biometricAwaitingAcceptance: "biometricAwaitingAcceptance",
|
||||
biometricFingerprintValidated: "biometricFingerprintValidated",
|
||||
biometricText: "biometricText",
|
||||
biometricUnlock: "biometric",
|
||||
clearClipboard: "clearClipboardKey",
|
||||
clientId: "apikey_clientId",
|
||||
clientSecret: "apikey_clientSecret",
|
||||
collapsedGroupings: "collapsedGroupings",
|
||||
convertAccountToKeyConnector: "convertAccountToKeyConnector",
|
||||
defaultUriMatch: "defaultUriMatch",
|
||||
disableAddLoginNotification: "disableAddLoginNotification",
|
||||
disableAutoBiometricsPrompt: "noAutoPromptBiometrics",
|
||||
disableAutoTotpCopy: "disableAutoTotpCopy",
|
||||
disableBadgeCounter: "disableBadgeCounter",
|
||||
disableChangedPasswordNotification: "disableChangedPasswordNotification",
|
||||
disableContextMenuItem: "disableContextMenuItem",
|
||||
disableFavicon: "disableFavicon",
|
||||
disableGa: "disableGa",
|
||||
dontShowCardsCurrentTab: "dontShowCardsCurrentTab",
|
||||
dontShowIdentitiesCurrentTab: "dontShowIdentitiesCurrentTab",
|
||||
emailVerified: "emailVerified",
|
||||
enableAlwaysOnTop: "enableAlwaysOnTopKey",
|
||||
enableAutoFillOnPageLoad: "enableAutoFillOnPageLoad",
|
||||
enableBiometric: "enabledBiometric",
|
||||
enableBrowserIntegration: "enableBrowserIntegration",
|
||||
enableBrowserIntegrationFingerprint: "enableBrowserIntegrationFingerprint",
|
||||
enableCloseToTray: "enableCloseToTray",
|
||||
enableFullWidth: "enableFullWidth",
|
||||
enableMinimizeToTray: "enableMinimizeToTray",
|
||||
enableStartToTray: "enableStartToTrayKey",
|
||||
enableTray: "enableTray",
|
||||
encKey: "encKey", // Generated Symmetric Key
|
||||
encOrgKeys: "encOrgKeys",
|
||||
encPrivate: "encPrivateKey",
|
||||
encProviderKeys: "encProviderKeys",
|
||||
entityId: "entityId",
|
||||
entityType: "entityType",
|
||||
environmentUrls: "environmentUrls",
|
||||
equivalentDomains: "equivalentDomains",
|
||||
eventCollection: "eventCollection",
|
||||
forcePasswordReset: "forcePasswordReset",
|
||||
history: "generatedPasswordHistory",
|
||||
installedVersion: "installedVersion",
|
||||
kdf: "kdf",
|
||||
kdfIterations: "kdfIterations",
|
||||
key: "key", // Master Key
|
||||
keyHash: "keyHash",
|
||||
lastActive: "lastActive",
|
||||
localData: "sitesLocalData",
|
||||
locale: "locale",
|
||||
mainWindowSize: "mainWindowSize",
|
||||
minimizeOnCopyToClipboard: "minimizeOnCopyToClipboardKey",
|
||||
neverDomains: "neverDomains",
|
||||
noAutoPromptBiometricsText: "noAutoPromptBiometricsText",
|
||||
openAtLogin: "openAtLogin",
|
||||
passwordGenerationOptions: "passwordGenerationOptions",
|
||||
pinProtected: "pinProtectedKey",
|
||||
protectedPin: "protectedPin",
|
||||
refreshToken: "refreshToken",
|
||||
ssoCodeVerifier: "ssoCodeVerifier",
|
||||
ssoIdentifier: "ssoOrgIdentifier",
|
||||
ssoState: "ssoState",
|
||||
stamp: "securityStamp",
|
||||
theme: "theme",
|
||||
userEmail: "userEmail",
|
||||
userId: "userId",
|
||||
usesConnector: "usesKeyConnector",
|
||||
vaultTimeoutAction: "vaultTimeoutAction",
|
||||
vaultTimeout: "lockOption",
|
||||
rememberedEmail: "rememberedEmail",
|
||||
};
|
||||
|
||||
const v1KeyPrefixes: { [key: string]: string } = {
|
||||
ciphers: "ciphers_",
|
||||
collections: "collections_",
|
||||
folders: "folders_",
|
||||
lastSync: "lastSync_",
|
||||
policies: "policies_",
|
||||
twoFactorToken: "twoFactorToken_",
|
||||
organizations: "organizations_",
|
||||
providers: "providers_",
|
||||
sends: "sends_",
|
||||
settings: "settings_",
|
||||
};
|
||||
|
||||
const keys = {
|
||||
global: "global",
|
||||
authenticatedAccounts: "authenticatedAccounts",
|
||||
activeUserId: "activeUserId",
|
||||
tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication
|
||||
accountActivity: "accountActivity",
|
||||
};
|
||||
|
||||
const partialKeys = {
|
||||
autoKey: "_masterkey_auto",
|
||||
biometricKey: "_masterkey_biometric",
|
||||
masterKey: "_masterkey",
|
||||
};
|
||||
|
||||
export class StateMigrationService<
|
||||
TGlobalState extends GlobalState = GlobalState,
|
||||
TAccount extends Account = Account
|
||||
> {
|
||||
constructor(
|
||||
protected storageService: AbstractStorageService,
|
||||
protected secureStorageService: AbstractStorageService,
|
||||
protected stateFactory: StateFactory<TGlobalState, TAccount>
|
||||
) {}
|
||||
|
||||
async needsMigration(): Promise<boolean> {
|
||||
const currentStateVersion = await this.getCurrentStateVersion();
|
||||
return currentStateVersion == null || currentStateVersion < StateVersion.Latest;
|
||||
}
|
||||
|
||||
async migrate(): Promise<void> {
|
||||
let currentStateVersion = await this.getCurrentStateVersion();
|
||||
while (currentStateVersion < StateVersion.Latest) {
|
||||
switch (currentStateVersion) {
|
||||
case StateVersion.One:
|
||||
await this.migrateStateFrom1To2();
|
||||
break;
|
||||
case StateVersion.Two:
|
||||
await this.migrateStateFrom2To3();
|
||||
break;
|
||||
case StateVersion.Three:
|
||||
await this.migrateStateFrom3To4();
|
||||
break;
|
||||
case StateVersion.Four: {
|
||||
const authenticatedAccounts = await this.getAuthenticatedAccounts();
|
||||
for (const account of authenticatedAccounts) {
|
||||
const migratedAccount = await this.migrateAccountFrom4To5(account);
|
||||
await this.set(account.profile.userId, migratedAccount);
|
||||
}
|
||||
await this.setCurrentStateVersion(StateVersion.Five);
|
||||
break;
|
||||
}
|
||||
case StateVersion.Five: {
|
||||
const authenticatedAccounts = await this.getAuthenticatedAccounts();
|
||||
for (const account of authenticatedAccounts) {
|
||||
const migratedAccount = await this.migrateAccountFrom5To6(account);
|
||||
await this.set(account.profile.userId, migratedAccount);
|
||||
}
|
||||
await this.setCurrentStateVersion(StateVersion.Six);
|
||||
break;
|
||||
}
|
||||
case StateVersion.Six: {
|
||||
const authenticatedAccounts = await this.getAuthenticatedAccounts();
|
||||
const globals = (await this.getGlobals()) as any;
|
||||
for (const account of authenticatedAccounts) {
|
||||
const migratedAccount = await this.migrateAccountFrom6To7(
|
||||
globals?.noAutoPromptBiometrics,
|
||||
account
|
||||
);
|
||||
await this.set(account.profile.userId, migratedAccount);
|
||||
}
|
||||
if (globals) {
|
||||
delete globals.noAutoPromptBiometrics;
|
||||
}
|
||||
await this.set(keys.global, globals);
|
||||
await this.setCurrentStateVersion(StateVersion.Seven);
|
||||
}
|
||||
}
|
||||
|
||||
currentStateVersion += 1;
|
||||
}
|
||||
}
|
||||
|
||||
protected async migrateStateFrom1To2(): Promise<void> {
|
||||
const clearV1Keys = async (clearingUserId?: string) => {
|
||||
for (const key in v1Keys) {
|
||||
if (key == null) {
|
||||
continue;
|
||||
}
|
||||
await this.set(v1Keys[key], null);
|
||||
}
|
||||
if (clearingUserId != null) {
|
||||
for (const keyPrefix in v1KeyPrefixes) {
|
||||
if (keyPrefix == null) {
|
||||
continue;
|
||||
}
|
||||
await this.set(v1KeyPrefixes[keyPrefix] + userId, null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Some processes, like biometrics, may have already defined a value before migrations are run.
|
||||
// We don't want to null out those values if they don't exist in the old storage scheme (like for new installs)
|
||||
// So, the OOO for migration is that we:
|
||||
// 1. Check for an existing storage value from the old storage structure OR
|
||||
// 2. Check for a value already set by processes that run before migration OR
|
||||
// 3. Assign the default value
|
||||
const globals: any =
|
||||
(await this.get<GlobalState>(keys.global)) ?? this.stateFactory.createGlobal(null);
|
||||
globals.stateVersion = StateVersion.Two;
|
||||
globals.environmentUrls =
|
||||
(await this.get<EnvironmentUrls>(v1Keys.environmentUrls)) ?? globals.environmentUrls;
|
||||
globals.locale = (await this.get<string>(v1Keys.locale)) ?? globals.locale;
|
||||
globals.noAutoPromptBiometrics =
|
||||
(await this.get<boolean>(v1Keys.disableAutoBiometricsPrompt)) ??
|
||||
globals.noAutoPromptBiometrics;
|
||||
globals.noAutoPromptBiometricsText =
|
||||
(await this.get<string>(v1Keys.noAutoPromptBiometricsText)) ??
|
||||
globals.noAutoPromptBiometricsText;
|
||||
globals.ssoCodeVerifier =
|
||||
(await this.get<string>(v1Keys.ssoCodeVerifier)) ?? globals.ssoCodeVerifier;
|
||||
globals.ssoOrganizationIdentifier =
|
||||
(await this.get<string>(v1Keys.ssoIdentifier)) ?? globals.ssoOrganizationIdentifier;
|
||||
globals.ssoState = (await this.get<any>(v1Keys.ssoState)) ?? globals.ssoState;
|
||||
globals.rememberedEmail =
|
||||
(await this.get<string>(v1Keys.rememberedEmail)) ?? globals.rememberedEmail;
|
||||
globals.theme = (await this.get<ThemeType>(v1Keys.theme)) ?? globals.theme;
|
||||
globals.vaultTimeout = (await this.get<number>(v1Keys.vaultTimeout)) ?? globals.vaultTimeout;
|
||||
globals.vaultTimeoutAction =
|
||||
(await this.get<string>(v1Keys.vaultTimeoutAction)) ?? globals.vaultTimeoutAction;
|
||||
globals.window = (await this.get<any>(v1Keys.mainWindowSize)) ?? globals.window;
|
||||
globals.enableTray = (await this.get<boolean>(v1Keys.enableTray)) ?? globals.enableTray;
|
||||
globals.enableMinimizeToTray =
|
||||
(await this.get<boolean>(v1Keys.enableMinimizeToTray)) ?? globals.enableMinimizeToTray;
|
||||
globals.enableCloseToTray =
|
||||
(await this.get<boolean>(v1Keys.enableCloseToTray)) ?? globals.enableCloseToTray;
|
||||
globals.enableStartToTray =
|
||||
(await this.get<boolean>(v1Keys.enableStartToTray)) ?? globals.enableStartToTray;
|
||||
globals.openAtLogin = (await this.get<boolean>(v1Keys.openAtLogin)) ?? globals.openAtLogin;
|
||||
globals.alwaysShowDock =
|
||||
(await this.get<boolean>(v1Keys.alwaysShowDock)) ?? globals.alwaysShowDock;
|
||||
globals.enableBrowserIntegration =
|
||||
(await this.get<boolean>(v1Keys.enableBrowserIntegration)) ??
|
||||
globals.enableBrowserIntegration;
|
||||
globals.enableBrowserIntegrationFingerprint =
|
||||
(await this.get<boolean>(v1Keys.enableBrowserIntegrationFingerprint)) ??
|
||||
globals.enableBrowserIntegrationFingerprint;
|
||||
|
||||
const userId =
|
||||
(await this.get<string>(v1Keys.userId)) ?? (await this.get<string>(v1Keys.entityId));
|
||||
|
||||
const defaultAccount = this.stateFactory.createAccount(null);
|
||||
const accountSettings: AccountSettings = {
|
||||
autoConfirmFingerPrints:
|
||||
(await this.get<boolean>(v1Keys.autoConfirmFingerprints)) ??
|
||||
defaultAccount.settings.autoConfirmFingerPrints,
|
||||
autoFillOnPageLoadDefault:
|
||||
(await this.get<boolean>(v1Keys.autoFillOnPageLoadDefault)) ??
|
||||
defaultAccount.settings.autoFillOnPageLoadDefault,
|
||||
biometricUnlock:
|
||||
(await this.get<boolean>(v1Keys.biometricUnlock)) ??
|
||||
defaultAccount.settings.biometricUnlock,
|
||||
clearClipboard:
|
||||
(await this.get<number>(v1Keys.clearClipboard)) ?? defaultAccount.settings.clearClipboard,
|
||||
defaultUriMatch:
|
||||
(await this.get<any>(v1Keys.defaultUriMatch)) ?? defaultAccount.settings.defaultUriMatch,
|
||||
disableAddLoginNotification:
|
||||
(await this.get<boolean>(v1Keys.disableAddLoginNotification)) ??
|
||||
defaultAccount.settings.disableAddLoginNotification,
|
||||
disableAutoBiometricsPrompt:
|
||||
(await this.get<boolean>(v1Keys.disableAutoBiometricsPrompt)) ??
|
||||
defaultAccount.settings.disableAutoBiometricsPrompt,
|
||||
disableAutoTotpCopy:
|
||||
(await this.get<boolean>(v1Keys.disableAutoTotpCopy)) ??
|
||||
defaultAccount.settings.disableAutoTotpCopy,
|
||||
disableBadgeCounter:
|
||||
(await this.get<boolean>(v1Keys.disableBadgeCounter)) ??
|
||||
defaultAccount.settings.disableBadgeCounter,
|
||||
disableChangedPasswordNotification:
|
||||
(await this.get<boolean>(v1Keys.disableChangedPasswordNotification)) ??
|
||||
defaultAccount.settings.disableChangedPasswordNotification,
|
||||
disableContextMenuItem:
|
||||
(await this.get<boolean>(v1Keys.disableContextMenuItem)) ??
|
||||
defaultAccount.settings.disableContextMenuItem,
|
||||
disableGa: (await this.get<boolean>(v1Keys.disableGa)) ?? defaultAccount.settings.disableGa,
|
||||
dontShowCardsCurrentTab:
|
||||
(await this.get<boolean>(v1Keys.dontShowCardsCurrentTab)) ??
|
||||
defaultAccount.settings.dontShowCardsCurrentTab,
|
||||
dontShowIdentitiesCurrentTab:
|
||||
(await this.get<boolean>(v1Keys.dontShowIdentitiesCurrentTab)) ??
|
||||
defaultAccount.settings.dontShowIdentitiesCurrentTab,
|
||||
enableAlwaysOnTop:
|
||||
(await this.get<boolean>(v1Keys.enableAlwaysOnTop)) ??
|
||||
defaultAccount.settings.enableAlwaysOnTop,
|
||||
enableAutoFillOnPageLoad:
|
||||
(await this.get<boolean>(v1Keys.enableAutoFillOnPageLoad)) ??
|
||||
defaultAccount.settings.enableAutoFillOnPageLoad,
|
||||
enableBiometric:
|
||||
(await this.get<boolean>(v1Keys.enableBiometric)) ??
|
||||
defaultAccount.settings.enableBiometric,
|
||||
enableFullWidth:
|
||||
(await this.get<boolean>(v1Keys.enableFullWidth)) ??
|
||||
defaultAccount.settings.enableFullWidth,
|
||||
environmentUrls: globals.environmentUrls ?? defaultAccount.settings.environmentUrls,
|
||||
equivalentDomains:
|
||||
(await this.get<any>(v1Keys.equivalentDomains)) ??
|
||||
defaultAccount.settings.equivalentDomains,
|
||||
minimizeOnCopyToClipboard:
|
||||
(await this.get<boolean>(v1Keys.minimizeOnCopyToClipboard)) ??
|
||||
defaultAccount.settings.minimizeOnCopyToClipboard,
|
||||
neverDomains:
|
||||
(await this.get<any>(v1Keys.neverDomains)) ?? defaultAccount.settings.neverDomains,
|
||||
passwordGenerationOptions:
|
||||
(await this.get<any>(v1Keys.passwordGenerationOptions)) ??
|
||||
defaultAccount.settings.passwordGenerationOptions,
|
||||
pinProtected: Object.assign(new EncryptionPair<string, EncString>(), {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<string>(v1Keys.pinProtected),
|
||||
}),
|
||||
protectedPin: await this.get<string>(v1Keys.protectedPin),
|
||||
settings:
|
||||
userId == null
|
||||
? null
|
||||
: await this.get<AccountSettingsSettings>(v1KeyPrefixes.settings + userId),
|
||||
vaultTimeout:
|
||||
(await this.get<number>(v1Keys.vaultTimeout)) ?? defaultAccount.settings.vaultTimeout,
|
||||
vaultTimeoutAction:
|
||||
(await this.get<string>(v1Keys.vaultTimeoutAction)) ??
|
||||
defaultAccount.settings.vaultTimeoutAction,
|
||||
};
|
||||
|
||||
// (userId == null) = no logged in user (so no known userId) and we need to temporarily store account specific settings in state to migrate on first auth
|
||||
// (userId != null) = we have a currently authed user (so known userId) with encrypted data and other key settings we can move, no need to temporarily store account settings
|
||||
if (userId == null) {
|
||||
await this.set(keys.tempAccountSettings, accountSettings);
|
||||
await this.set(keys.global, globals);
|
||||
await this.set(keys.authenticatedAccounts, []);
|
||||
await this.set(keys.activeUserId, null);
|
||||
await clearV1Keys();
|
||||
return;
|
||||
}
|
||||
|
||||
globals.twoFactorToken = await this.get<string>(v1KeyPrefixes.twoFactorToken + userId);
|
||||
await this.set(keys.global, globals);
|
||||
await this.set(userId, {
|
||||
data: {
|
||||
addEditCipherInfo: null,
|
||||
ciphers: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<{ [id: string]: CipherData }>(v1KeyPrefixes.ciphers + userId),
|
||||
},
|
||||
collapsedGroupings: null,
|
||||
collections: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<{ [id: string]: CollectionData }>(
|
||||
v1KeyPrefixes.collections + userId
|
||||
),
|
||||
},
|
||||
eventCollection: await this.get<EventData[]>(v1Keys.eventCollection),
|
||||
folders: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<{ [id: string]: FolderData }>(v1KeyPrefixes.folders + userId),
|
||||
},
|
||||
localData: null,
|
||||
organizations: await this.get<{ [id: string]: OrganizationData }>(
|
||||
v1KeyPrefixes.organizations + userId
|
||||
),
|
||||
passwordGenerationHistory: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<GeneratedPasswordHistory[]>(v1Keys.history),
|
||||
},
|
||||
policies: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<{ [id: string]: PolicyData }>(v1KeyPrefixes.policies + userId),
|
||||
},
|
||||
providers: await this.get<{ [id: string]: ProviderData }>(v1KeyPrefixes.providers + userId),
|
||||
sends: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<{ [id: string]: SendData }>(v1KeyPrefixes.sends + userId),
|
||||
},
|
||||
},
|
||||
keys: {
|
||||
apiKeyClientSecret: await this.get<string>(v1Keys.clientSecret),
|
||||
cryptoMasterKey: null,
|
||||
cryptoMasterKeyAuto: null,
|
||||
cryptoMasterKeyB64: null,
|
||||
cryptoMasterKeyBiometric: null,
|
||||
cryptoSymmetricKey: {
|
||||
encrypted: await this.get<string>(v1Keys.encKey),
|
||||
decrypted: null,
|
||||
},
|
||||
legacyEtmKey: null,
|
||||
organizationKeys: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<any>(v1Keys.encOrgKeys),
|
||||
},
|
||||
privateKey: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<string>(v1Keys.encPrivate),
|
||||
},
|
||||
providerKeys: {
|
||||
decrypted: null,
|
||||
encrypted: await this.get<any>(v1Keys.encProviderKeys),
|
||||
},
|
||||
publicKey: null,
|
||||
},
|
||||
profile: {
|
||||
apiKeyClientId: await this.get<string>(v1Keys.clientId),
|
||||
authenticationStatus: null,
|
||||
convertAccountToKeyConnector: await this.get<boolean>(v1Keys.convertAccountToKeyConnector),
|
||||
email: await this.get<string>(v1Keys.userEmail),
|
||||
emailVerified: await this.get<boolean>(v1Keys.emailVerified),
|
||||
entityId: null,
|
||||
entityType: null,
|
||||
everBeenUnlocked: null,
|
||||
forcePasswordReset: null,
|
||||
hasPremiumPersonally: null,
|
||||
kdfIterations: await this.get<number>(v1Keys.kdfIterations),
|
||||
kdfType: await this.get<KdfType>(v1Keys.kdf),
|
||||
keyHash: await this.get<string>(v1Keys.keyHash),
|
||||
lastSync: null,
|
||||
userId: userId,
|
||||
usesKeyConnector: null,
|
||||
},
|
||||
settings: accountSettings,
|
||||
tokens: {
|
||||
accessToken: await this.get<string>(v1Keys.accessToken),
|
||||
decodedToken: null,
|
||||
refreshToken: await this.get<string>(v1Keys.refreshToken),
|
||||
securityStamp: null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.set(keys.authenticatedAccounts, [userId]);
|
||||
await this.set(keys.activeUserId, userId);
|
||||
|
||||
const accountActivity: { [userId: string]: number } = {
|
||||
[userId]: await this.get<number>(v1Keys.lastActive),
|
||||
};
|
||||
accountActivity[userId] = await this.get<number>(v1Keys.lastActive);
|
||||
await this.set(keys.accountActivity, accountActivity);
|
||||
|
||||
await clearV1Keys(userId);
|
||||
|
||||
if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "biometric" })) {
|
||||
await this.secureStorageService.save(
|
||||
`${userId}${partialKeys.biometricKey}`,
|
||||
await this.secureStorageService.get(v1Keys.key, { keySuffix: "biometric" }),
|
||||
{ keySuffix: "biometric" }
|
||||
);
|
||||
await this.secureStorageService.remove(v1Keys.key, { keySuffix: "biometric" });
|
||||
}
|
||||
|
||||
if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "auto" })) {
|
||||
await this.secureStorageService.save(
|
||||
`${userId}${partialKeys.autoKey}`,
|
||||
await this.secureStorageService.get(v1Keys.key, { keySuffix: "auto" }),
|
||||
{ keySuffix: "auto" }
|
||||
);
|
||||
await this.secureStorageService.remove(v1Keys.key, { keySuffix: "auto" });
|
||||
}
|
||||
|
||||
if (await this.secureStorageService.has(v1Keys.key)) {
|
||||
await this.secureStorageService.save(
|
||||
`${userId}${partialKeys.masterKey}`,
|
||||
await this.secureStorageService.get(v1Keys.key)
|
||||
);
|
||||
await this.secureStorageService.remove(v1Keys.key);
|
||||
}
|
||||
}
|
||||
|
||||
protected async migrateStateFrom2To3(): Promise<void> {
|
||||
const authenticatedUserIds = await this.get<string[]>(keys.authenticatedAccounts);
|
||||
await Promise.all(
|
||||
authenticatedUserIds.map(async (userId) => {
|
||||
const account = await this.get<TAccount>(userId);
|
||||
if (
|
||||
account?.profile?.hasPremiumPersonally === null &&
|
||||
account.tokens?.accessToken != null
|
||||
) {
|
||||
const decodedToken = await TokenService.decodeToken(account.tokens.accessToken);
|
||||
account.profile.hasPremiumPersonally = decodedToken.premium;
|
||||
await this.set(userId, account);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Three;
|
||||
await this.set(keys.global, globals);
|
||||
}
|
||||
|
||||
protected async migrateStateFrom3To4(): Promise<void> {
|
||||
const authenticatedUserIds = await this.get<string[]>(keys.authenticatedAccounts);
|
||||
await Promise.all(
|
||||
authenticatedUserIds.map(async (userId) => {
|
||||
const account = await this.get<TAccount>(userId);
|
||||
if (account?.profile?.everBeenUnlocked != null) {
|
||||
delete account.profile.everBeenUnlocked;
|
||||
return this.set(userId, account);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Four;
|
||||
await this.set(keys.global, globals);
|
||||
}
|
||||
|
||||
protected async migrateAccountFrom4To5(account: TAccount): Promise<TAccount> {
|
||||
const encryptedOrgKeys = account.keys?.organizationKeys?.encrypted;
|
||||
if (encryptedOrgKeys != null) {
|
||||
for (const [orgId, encKey] of Object.entries(encryptedOrgKeys)) {
|
||||
encryptedOrgKeys[orgId] = {
|
||||
type: "organization",
|
||||
key: encKey as unknown as string, // Account v4 does not reflect the current account model so we have to cast
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
protected async migrateAccountFrom5To6(account: TAccount): Promise<TAccount> {
|
||||
delete (account as any).keys?.legacyEtmKey;
|
||||
return account;
|
||||
}
|
||||
|
||||
protected async migrateAccountFrom6To7(
|
||||
globalSetting: boolean,
|
||||
account: TAccount
|
||||
): Promise<TAccount> {
|
||||
if (globalSetting) {
|
||||
account.settings = Object.assign({}, account.settings, { disableAutoBiometricsPrompt: true });
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
protected get options(): StorageOptions {
|
||||
return { htmlStorageLocation: HtmlStorageLocation.Local };
|
||||
}
|
||||
|
||||
protected get<T>(key: string): Promise<T> {
|
||||
return this.storageService.get<T>(key, this.options);
|
||||
}
|
||||
|
||||
protected set(key: string, value: any): Promise<any> {
|
||||
if (value == null) {
|
||||
return this.storageService.remove(key, this.options);
|
||||
}
|
||||
return this.storageService.save(key, value, this.options);
|
||||
}
|
||||
|
||||
protected async getGlobals(): Promise<TGlobalState> {
|
||||
return await this.get<TGlobalState>(keys.global);
|
||||
}
|
||||
|
||||
protected async getCurrentStateVersion(): Promise<StateVersion> {
|
||||
return (await this.getGlobals())?.stateVersion ?? StateVersion.One;
|
||||
}
|
||||
|
||||
protected async setCurrentStateVersion(newVersion: StateVersion): Promise<void> {
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = newVersion;
|
||||
await this.set(keys.global, globals);
|
||||
}
|
||||
|
||||
protected async getAuthenticatedAccounts(): Promise<TAccount[]> {
|
||||
const authenticatedUserIds = await this.get<string[]>(keys.authenticatedAccounts);
|
||||
return Promise.all(authenticatedUserIds.map((id) => this.get<TAccount>(id)));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { EventData } from "../../models/data/event.data";
|
||||
import { WindowState } from "../../models/domain/window-state";
|
||||
import { migrate } from "../../state-migrations";
|
||||
import { GeneratedPasswordHistory } from "../../tools/generator/password";
|
||||
import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
@@ -32,7 +33,6 @@ import { CipherView } from "../../vault/models/view/cipher.view";
|
||||
import { CollectionView } from "../../vault/models/view/collection.view";
|
||||
import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
import { StateMigrationService } from "../abstractions/state-migration.service";
|
||||
import { StateService as StateServiceAbstraction } from "../abstractions/state.service";
|
||||
import {
|
||||
AbstractMemoryStorageService,
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
|
||||
const keys = {
|
||||
state: "state",
|
||||
stateVersion: "stateVersion",
|
||||
global: "global",
|
||||
authenticatedAccounts: "authenticatedAccounts",
|
||||
activeUserId: "activeUserId",
|
||||
@@ -106,7 +107,6 @@ export class StateService<
|
||||
protected secureStorageService: AbstractStorageService,
|
||||
protected memoryStorageService: AbstractMemoryStorageService,
|
||||
protected logService: LogService,
|
||||
protected stateMigrationService: StateMigrationService,
|
||||
protected stateFactory: StateFactory<TGlobalState, TAccount>,
|
||||
protected useAccountCache: boolean = true
|
||||
) {
|
||||
@@ -133,9 +133,7 @@ export class StateService<
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.stateMigrationService.needsMigration()) {
|
||||
await this.stateMigrationService.migrate();
|
||||
}
|
||||
await migrate(this.storageService, this.logService);
|
||||
|
||||
await this.state().then(async (state) => {
|
||||
if (state == null) {
|
||||
@@ -2724,16 +2722,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getStateVersion(): Promise<number> {
|
||||
return (await this.getGlobals(await this.defaultOnDiskLocalOptions())).stateVersion ?? 1;
|
||||
}
|
||||
|
||||
async setStateVersion(value: number): Promise<void> {
|
||||
const globals = await this.getGlobals(await this.defaultOnDiskOptions());
|
||||
globals.stateVersion = value;
|
||||
await this.saveGlobals(globals, await this.defaultOnDiskOptions());
|
||||
}
|
||||
|
||||
async getWindow(): Promise<WindowState> {
|
||||
const globals = await this.getGlobals(await this.defaultOnDiskOptions());
|
||||
return globals?.window != null && Object.keys(globals.window).length > 0
|
||||
@@ -2838,7 +2826,11 @@ export class StateService<
|
||||
globals = await this.getGlobalsFromDisk(options);
|
||||
}
|
||||
|
||||
return globals ?? this.createGlobals();
|
||||
if (globals == null) {
|
||||
globals = this.createGlobals();
|
||||
}
|
||||
|
||||
return globals;
|
||||
}
|
||||
|
||||
protected async saveGlobals(globals: TGlobalState, options: StorageOptions) {
|
||||
|
||||
24
libs/common/src/state-migrations/.eslintrc.json
Normal file
24
libs/common/src/state-migrations/.eslintrc.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*"],
|
||||
"rules": {
|
||||
"import/no-restricted-paths": [
|
||||
"error",
|
||||
{
|
||||
"basePath": "libs/common/src/state-migrations",
|
||||
"zones": [
|
||||
{
|
||||
"target": "./",
|
||||
"from": "../",
|
||||
// Relative to from, not basePath
|
||||
"except": ["state-migrations"],
|
||||
"message": "State migrations should rarely import from the greater codebase. If you need to import from another location, take into account the likelihood of change in that code and consider copying to the migration instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
1
libs/common/src/state-migrations/index.ts
Normal file
1
libs/common/src/state-migrations/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { migrate, CURRENT_VERSION } from "./migrate";
|
||||
67
libs/common/src/state-migrations/migrate.spec.ts
Normal file
67
libs/common/src/state-migrations/migrate.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
import { AbstractStorageService } from "../platform/abstractions/storage.service";
|
||||
|
||||
import { CURRENT_VERSION, currentVersion, migrate } from "./migrate";
|
||||
import { MigrationBuilder } from "./migration-builder";
|
||||
|
||||
jest.mock("./migration-builder", () => {
|
||||
return {
|
||||
MigrationBuilder: {
|
||||
create: jest.fn().mockReturnThis(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
it("should not run migrations if state is empty", async () => {
|
||||
const storage = mock<AbstractStorageService>();
|
||||
const logService = mock<LogService>();
|
||||
storage.get.mockReturnValueOnce(null);
|
||||
await migrate(storage, logService);
|
||||
expect(MigrationBuilder.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set to current version if state is empty", async () => {
|
||||
const storage = mock<AbstractStorageService>();
|
||||
const logService = mock<LogService>();
|
||||
storage.get.mockReturnValueOnce(null);
|
||||
await migrate(storage, logService);
|
||||
expect(storage.save).toHaveBeenCalledWith("stateVersion", CURRENT_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentVersion", () => {
|
||||
let storage: MockProxy<AbstractStorageService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = mock();
|
||||
logService = mock();
|
||||
});
|
||||
|
||||
it("should return -1 if no version", async () => {
|
||||
storage.get.mockReturnValueOnce(null);
|
||||
expect(await currentVersion(storage, logService)).toEqual(-1);
|
||||
});
|
||||
|
||||
it("should return version", async () => {
|
||||
storage.get.calledWith("stateVersion").mockReturnValueOnce(1 as any);
|
||||
expect(await currentVersion(storage, logService)).toEqual(1);
|
||||
});
|
||||
|
||||
it("should return version from global", async () => {
|
||||
storage.get.calledWith("stateVersion").mockReturnValueOnce(null);
|
||||
storage.get.calledWith("global").mockReturnValueOnce({ stateVersion: 1 } as any);
|
||||
expect(await currentVersion(storage, logService)).toEqual(1);
|
||||
});
|
||||
|
||||
it("should prefer root version to global", async () => {
|
||||
storage.get.calledWith("stateVersion").mockReturnValue(1 as any);
|
||||
storage.get.calledWith("global").mockReturnValue({ stateVersion: 2 } as any);
|
||||
expect(await currentVersion(storage, logService)).toEqual(1);
|
||||
});
|
||||
});
|
||||
60
libs/common/src/state-migrations/migrate.ts
Normal file
60
libs/common/src/state-migrations/migrate.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
import { AbstractStorageService } from "../platform/abstractions/storage.service";
|
||||
|
||||
import { MigrationBuilder } from "./migration-builder";
|
||||
import { MigrationHelper } from "./migration-helper";
|
||||
import { FixPremiumMigrator } from "./migrations/3-fix-premium";
|
||||
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
|
||||
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
|
||||
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
|
||||
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
|
||||
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
|
||||
import { MinVersionMigrator } from "./migrations/min-version";
|
||||
|
||||
export const MIN_VERSION = 2;
|
||||
export const CURRENT_VERSION = 8;
|
||||
export type MinVersion = typeof MIN_VERSION;
|
||||
|
||||
export async function migrate(
|
||||
storageService: AbstractStorageService,
|
||||
logService: LogService
|
||||
): Promise<void> {
|
||||
const migrationHelper = new MigrationHelper(
|
||||
await currentVersion(storageService, logService),
|
||||
storageService,
|
||||
logService
|
||||
);
|
||||
if (migrationHelper.currentVersion < 0) {
|
||||
// Cannot determine state, assuming empty so we don't repeatedly apply a migration.
|
||||
await storageService.save("stateVersion", CURRENT_VERSION);
|
||||
return;
|
||||
}
|
||||
MigrationBuilder.create()
|
||||
.with(MinVersionMigrator)
|
||||
.with(FixPremiumMigrator, 2, 3)
|
||||
.with(RemoveEverBeenUnlockedMigrator, 3, 4)
|
||||
.with(AddKeyTypeToOrgKeysMigrator, 4, 5)
|
||||
.with(RemoveLegacyEtmKeyMigrator, 5, 6)
|
||||
.with(MoveBiometricAutoPromptToAccount, 6, 7)
|
||||
.with(MoveStateVersionMigrator, 7, CURRENT_VERSION)
|
||||
.migrate(migrationHelper);
|
||||
}
|
||||
|
||||
export async function currentVersion(
|
||||
storageService: AbstractStorageService,
|
||||
logService: LogService
|
||||
) {
|
||||
let state = await storageService.get<number>("stateVersion");
|
||||
if (state == null) {
|
||||
// Pre v8
|
||||
state = (await storageService.get<{ stateVersion: number }>("global"))?.stateVersion;
|
||||
}
|
||||
if (state == null) {
|
||||
logService.info("No state version found, assuming empty state.");
|
||||
return -1;
|
||||
}
|
||||
logService.info(`State version: ${state}`);
|
||||
return state;
|
||||
}
|
||||
117
libs/common/src/state-migrations/migration-builder.spec.ts
Normal file
117
libs/common/src/state-migrations/migration-builder.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { MigrationBuilder } from "./migration-builder";
|
||||
import { MigrationHelper } from "./migration-helper";
|
||||
import { Migrator } from "./migrator";
|
||||
|
||||
describe("MigrationBuilder", () => {
|
||||
class TestMigrator extends Migrator<0, 1> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
await helper.set("test", "test");
|
||||
return;
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
await helper.set("test", "rollback");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let sut: MigrationBuilder<number>;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = MigrationBuilder.create();
|
||||
});
|
||||
|
||||
class TestBadMigrator extends Migrator<1, 0> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
await helper.set("test", "test");
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
await helper.set("test", "rollback");
|
||||
}
|
||||
}
|
||||
|
||||
it("should throw if instantiated incorrectly", () => {
|
||||
expect(() => MigrationBuilder.create().with(TestMigrator, null, null)).toThrow();
|
||||
expect(() =>
|
||||
MigrationBuilder.create().with(TestMigrator, 0, 1).with(TestBadMigrator, 1, 0)
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("should be able to create a new MigrationBuilder", () => {
|
||||
expect(sut).toBeInstanceOf(MigrationBuilder);
|
||||
});
|
||||
|
||||
it("should be able to add a migrator", () => {
|
||||
const newBuilder = sut.with(TestMigrator, 0, 1);
|
||||
const migrations = newBuilder["migrations"];
|
||||
expect(migrations.length).toBe(1);
|
||||
expect(migrations[0]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "up" });
|
||||
});
|
||||
|
||||
it("should be able to add a rollback", () => {
|
||||
const newBuilder = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
|
||||
const migrations = newBuilder["migrations"];
|
||||
expect(migrations.length).toBe(2);
|
||||
expect(migrations[1]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "down" });
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
let migrator: TestMigrator;
|
||||
let rollback_migrator: TestMigrator;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0);
|
||||
migrator = (sut as any).migrations[0].migrator;
|
||||
rollback_migrator = (sut as any).migrations[1].migrator;
|
||||
});
|
||||
|
||||
it("should migrate", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock());
|
||||
const spy = jest.spyOn(migrator, "migrate");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper);
|
||||
});
|
||||
|
||||
it("should rollback", async () => {
|
||||
const helper = new MigrationHelper(1, mock(), mock());
|
||||
const spy = jest.spyOn(rollback_migrator, "rollback");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper);
|
||||
});
|
||||
|
||||
it("should update version on migrate", async () => {
|
||||
const helper = new MigrationHelper(0, mock(), mock());
|
||||
const spy = jest.spyOn(migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper, "up");
|
||||
});
|
||||
|
||||
it("should update version on rollback", async () => {
|
||||
const helper = new MigrationHelper(1, mock(), mock());
|
||||
const spy = jest.spyOn(rollback_migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(spy).toBeCalledWith(helper, "down");
|
||||
});
|
||||
|
||||
it("should not run the migrator if the current version does not match the from version", async () => {
|
||||
const helper = new MigrationHelper(3, mock(), mock());
|
||||
const migrate = jest.spyOn(migrator, "migrate");
|
||||
const rollback = jest.spyOn(rollback_migrator, "rollback");
|
||||
await sut.migrate(helper);
|
||||
expect(migrate).not.toBeCalled();
|
||||
expect(rollback).not.toBeCalled();
|
||||
});
|
||||
|
||||
it("should not update version if the current version does not match the from version", async () => {
|
||||
const helper = new MigrationHelper(3, mock(), mock());
|
||||
const migrate = jest.spyOn(migrator, "updateVersion");
|
||||
const rollback = jest.spyOn(rollback_migrator, "updateVersion");
|
||||
await sut.migrate(helper);
|
||||
expect(migrate).not.toBeCalled();
|
||||
expect(rollback).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
106
libs/common/src/state-migrations/migration-builder.ts
Normal file
106
libs/common/src/state-migrations/migration-builder.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { MigrationHelper } from "./migration-helper";
|
||||
import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator";
|
||||
|
||||
export class MigrationBuilder<TCurrent extends number = 0> {
|
||||
/** Create a new MigrationBuilder with an empty buffer of migrations to perform.
|
||||
*
|
||||
* Add migrations to the buffer with {@link with} and {@link rollback}.
|
||||
* @returns A new MigrationBuilder.
|
||||
*/
|
||||
static create(): MigrationBuilder<0> {
|
||||
return new MigrationBuilder([]);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private migrations: readonly { migrator: Migrator<number, number>; direction: Direction }[]
|
||||
) {}
|
||||
|
||||
/** Add a migrator to the MigrationBuilder. Types are updated such that the chained MigrationBuilder must currently be
|
||||
* at state version equal to the from version of the migrator. Return as MigrationBuilder<TTo> where TTo is the to
|
||||
* version of the migrator, so that the next migrator can be chained.
|
||||
*
|
||||
* @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
|
||||
* required to instantiate version numbers unless a default constructor is defined.
|
||||
* @returns A new MigrationBuilder with the to version of the migrator as the current version.
|
||||
*/
|
||||
with<
|
||||
TMigrator extends Migrator<number, number>,
|
||||
TFrom extends VersionFrom<TMigrator> & TCurrent,
|
||||
TTo extends VersionTo<TMigrator>
|
||||
>(
|
||||
...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo]
|
||||
): MigrationBuilder<TTo> {
|
||||
return this.addMigrator(migrate, "up");
|
||||
}
|
||||
|
||||
/** Add a migrator to rollback on the MigrationBuilder's list of migrations. As with {@link with}, types of
|
||||
* MigrationBuilder and Migrator must align. However, this time the migration is reversed so TCurrent of the
|
||||
* MigrationBuilder must be equal to the to version of the migrator. Return as MigrationBuilder<TFrom> where TFrom
|
||||
* is the from version of the migrator, so that the next migrator can be chained.
|
||||
*
|
||||
* @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is
|
||||
* required to instantiate version numbers unless a default constructor is defined.
|
||||
* @returns A new MigrationBuilder with the from version of the migrator as the current version.
|
||||
*/
|
||||
rollback<
|
||||
TMigrator extends Migrator<number, number>,
|
||||
TFrom extends VersionFrom<TMigrator>,
|
||||
TTo extends VersionTo<TMigrator> & TCurrent
|
||||
>(
|
||||
...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom]
|
||||
): MigrationBuilder<TFrom> {
|
||||
if (migrate.length === 3) {
|
||||
migrate = [migrate[0], migrate[2], migrate[1]];
|
||||
}
|
||||
return this.addMigrator(migrate, "down");
|
||||
}
|
||||
|
||||
/** Execute the migrations as defined in the MigrationBuilder's migrator buffer */
|
||||
migrate(helper: MigrationHelper): Promise<void> {
|
||||
return this.migrations.reduce(
|
||||
(promise, migrator) =>
|
||||
promise.then(async () => {
|
||||
await this.runMigrator(migrator.migrator, helper, migrator.direction);
|
||||
}),
|
||||
Promise.resolve()
|
||||
);
|
||||
}
|
||||
|
||||
private addMigrator<
|
||||
TMigrator extends Migrator<number, number>,
|
||||
TFrom extends VersionFrom<TMigrator> & TCurrent,
|
||||
TTo extends VersionTo<TMigrator>
|
||||
>(
|
||||
migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo],
|
||||
direction: Direction = "up"
|
||||
) {
|
||||
const newMigration =
|
||||
migrate.length === 1
|
||||
? { migrator: new migrate[0](), direction }
|
||||
: { migrator: new migrate[0](migrate[1], migrate[2]), direction };
|
||||
|
||||
return new MigrationBuilder<TTo>([...this.migrations, newMigration]);
|
||||
}
|
||||
|
||||
private async runMigrator(
|
||||
migrator: Migrator<number, number>,
|
||||
helper: MigrationHelper,
|
||||
direction: Direction
|
||||
): Promise<void> {
|
||||
const shouldMigrate = await migrator.shouldMigrate(helper, direction);
|
||||
helper.info(
|
||||
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) should migrate: ${shouldMigrate} - ${direction}`
|
||||
);
|
||||
if (shouldMigrate) {
|
||||
const method = direction === "up" ? migrator.migrate : migrator.rollback;
|
||||
await method(helper);
|
||||
helper.info(
|
||||
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) migrated - ${direction}`
|
||||
);
|
||||
await migrator.updateVersion(helper, direction);
|
||||
helper.info(
|
||||
`Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) updated version - ${direction}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
84
libs/common/src/state-migrations/migration-helper.spec.ts
Normal file
84
libs/common/src/state-migrations/migration-helper.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
import { AbstractStorageService } from "../platform/abstractions/storage.service";
|
||||
|
||||
import { MigrationHelper } from "./migration-helper";
|
||||
|
||||
const exampleJSON = {
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
};
|
||||
|
||||
describe("RemoveLegacyEtmKeyMigrator", () => {
|
||||
let storage: MockProxy<AbstractStorageService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let sut: MigrationHelper;
|
||||
|
||||
beforeEach(() => {
|
||||
logService = mock();
|
||||
storage = mock();
|
||||
storage.get.mockImplementation((key) => (exampleJSON as any)[key]);
|
||||
|
||||
sut = new MigrationHelper(0, storage, logService);
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("should delegate to storage.get", async () => {
|
||||
await sut.get("key");
|
||||
expect(storage.get).toHaveBeenCalledWith("key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("set", () => {
|
||||
it("should delegate to storage.save", async () => {
|
||||
await sut.set("key", "value");
|
||||
expect(storage.save).toHaveBeenCalledWith("key", "value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAccounts", () => {
|
||||
it("should return all accounts", async () => {
|
||||
const accounts = await sut.getAccounts();
|
||||
expect(accounts).toEqual([
|
||||
{ userId: "c493ed01-4e08-4e88-abc7-332f380ca760", account: { otherStuff: "otherStuff1" } },
|
||||
{ userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9", account: { otherStuff: "otherStuff2" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle missing authenticatedAccounts", async () => {
|
||||
storage.get.mockImplementation((key) =>
|
||||
key === "authenticatedAccounts" ? undefined : (exampleJSON as any)[key]
|
||||
);
|
||||
const accounts = await sut.getAccounts();
|
||||
expect(accounts).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Helper to create well-mocked migration helpers in migration tests */
|
||||
export function mockMigrationHelper(storageJson: any): MockProxy<MigrationHelper> {
|
||||
const logService: MockProxy<LogService> = mock();
|
||||
const storage: MockProxy<AbstractStorageService> = mock();
|
||||
storage.get.mockImplementation((key) => (storageJson as any)[key]);
|
||||
storage.save.mockImplementation(async (key, value) => {
|
||||
(storageJson as any)[key] = value;
|
||||
});
|
||||
const helper = new MigrationHelper(0, storage, logService);
|
||||
|
||||
const mockHelper = mock<MigrationHelper>();
|
||||
mockHelper.get.mockImplementation((key) => helper.get(key));
|
||||
mockHelper.set.mockImplementation((key, value) => helper.set(key, value));
|
||||
mockHelper.getAccounts.mockImplementation(() => helper.getAccounts());
|
||||
return mockHelper;
|
||||
}
|
||||
37
libs/common/src/state-migrations/migration-helper.ts
Normal file
37
libs/common/src/state-migrations/migration-helper.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
import { AbstractStorageService } from "../platform/abstractions/storage.service";
|
||||
|
||||
export class MigrationHelper {
|
||||
constructor(
|
||||
public currentVersion: number,
|
||||
private storageService: AbstractStorageService,
|
||||
public logService: LogService
|
||||
) {}
|
||||
|
||||
get<T>(key: string): Promise<T> {
|
||||
return this.storageService.get<T>(key);
|
||||
}
|
||||
|
||||
set<T>(key: string, value: T): Promise<void> {
|
||||
this.logService.info(`Setting ${key}`);
|
||||
return this.storageService.save(key, value);
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
this.logService.info(message);
|
||||
}
|
||||
|
||||
async getAccounts<ExpectedAccountType>(): Promise<
|
||||
{ userId: string; account: ExpectedAccountType }[]
|
||||
> {
|
||||
const userIds = (await this.get<string[]>("authenticatedAccounts")) ?? [];
|
||||
return Promise.all(
|
||||
userIds.map(async (userId) => ({
|
||||
userId,
|
||||
account: await this.get<ExpectedAccountType>(userId),
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Used for testing migration, which requires import
|
||||
import { TokenService } from "../../auth/services/token.service";
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { FixPremiumMigrator } from "./3-fix-premium";
|
||||
|
||||
function migrateExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 2,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
profile: {
|
||||
otherStuff: "otherStuff2",
|
||||
hasPremiumPersonally: null as boolean,
|
||||
},
|
||||
tokens: {
|
||||
otherStuff: "otherStuff3",
|
||||
accessToken: "accessToken",
|
||||
},
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
profile: {
|
||||
otherStuff: "otherStuff5",
|
||||
hasPremiumPersonally: true,
|
||||
},
|
||||
tokens: {
|
||||
otherStuff: "otherStuff6",
|
||||
accessToken: "accessToken",
|
||||
},
|
||||
otherStuff: "otherStuff7",
|
||||
},
|
||||
otherStuff: "otherStuff8",
|
||||
};
|
||||
}
|
||||
|
||||
jest.mock("../../auth/services/token.service", () => ({
|
||||
TokenService: {
|
||||
decodeToken: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("FixPremiumMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: FixPremiumMigrator;
|
||||
const decodeTokenSpy = TokenService.decodeToken as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(migrateExampleJSON());
|
||||
sut = new FixPremiumMigrator(2, 3);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
it("should migrate hasPremiumPersonally", async () => {
|
||||
decodeTokenSpy.mockResolvedValueOnce({ premium: true });
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
profile: {
|
||||
otherStuff: "otherStuff2",
|
||||
hasPremiumPersonally: true,
|
||||
},
|
||||
tokens: {
|
||||
otherStuff: "otherStuff3",
|
||||
accessToken: "accessToken",
|
||||
},
|
||||
otherStuff: "otherStuff4",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not migrate if decode throws", async () => {
|
||||
decodeTokenSpy.mockRejectedValueOnce(new Error("test"));
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not migrate if decode returns null", async () => {
|
||||
decodeTokenSpy.mockResolvedValueOnce(null);
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVersion", () => {
|
||||
it("should update version", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 3,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
48
libs/common/src/state-migrations/migrations/3-fix-premium.ts
Normal file
48
libs/common/src/state-migrations/migrations/3-fix-premium.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Used for token decoding, which are valid for days. We want the latest
|
||||
import { TokenService } from "../../auth/services/token.service";
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Migrator, IRREVERSIBLE, Direction } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
profile?: { hasPremiumPersonally?: boolean };
|
||||
tokens?: { accessToken?: string };
|
||||
};
|
||||
|
||||
export class FixPremiumMigrator extends Migrator<2, 3> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function fixPremium(userId: string, account: ExpectedAccountType) {
|
||||
if (account?.profile?.hasPremiumPersonally === null && account.tokens?.accessToken != null) {
|
||||
let decodedToken: { premium: boolean };
|
||||
try {
|
||||
decodedToken = await TokenService.decodeToken(account.tokens.accessToken);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (decodedToken?.premium == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
account.profile.hasPremiumPersonally = decodedToken?.premium;
|
||||
return helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => fixPremium(userId, account)));
|
||||
}
|
||||
|
||||
rollback(helper: MigrationHelper): Promise<void> {
|
||||
throw IRREVERSIBLE;
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
|
||||
// it is nested inside a global object.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
const global: Record<string, unknown> = (await helper.get("global")) || {};
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { RemoveEverBeenUnlockedMigrator } from "./4-remove-ever-been-unlocked";
|
||||
|
||||
function migrateExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 3,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
profile: {
|
||||
otherStuff: "otherStuff2",
|
||||
everBeenUnlocked: true,
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
profile: {
|
||||
otherStuff: "otherStuff4",
|
||||
everBeenUnlocked: false,
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
otherStuff: "otherStuff6",
|
||||
};
|
||||
}
|
||||
|
||||
describe("RemoveEverBeenUnlockedMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: RemoveEverBeenUnlockedMigrator;
|
||||
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(migrateExampleJSON());
|
||||
sut = new RemoveEverBeenUnlockedMigrator(3, 4);
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
it("should remove everBeenUnlocked from profile", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
profile: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
|
||||
profile: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVersion", () => {
|
||||
it("should update version up", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 4,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = { profile?: { everBeenUnlocked?: boolean } };
|
||||
|
||||
export class RemoveEverBeenUnlockedMigrator extends Migrator<3, 4> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function removeEverBeenUnlocked(userId: string, account: ExpectedAccountType) {
|
||||
if (account?.profile?.everBeenUnlocked != null) {
|
||||
delete account.profile.everBeenUnlocked;
|
||||
return helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(accounts.map(({ userId, account }) => removeEverBeenUnlocked(userId, account)));
|
||||
}
|
||||
|
||||
rollback(helper: MigrationHelper): Promise<void> {
|
||||
throw IRREVERSIBLE;
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
|
||||
// it is nested inside a global object.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { AddKeyTypeToOrgKeysMigrator } from "./5-add-key-type-to-org-keys";
|
||||
|
||||
function migrateExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 4,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: "orgOneEncKey",
|
||||
orgTwoId: "orgTwoEncKey",
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 5,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: {
|
||||
type: "organization",
|
||||
key: "orgOneEncKey",
|
||||
},
|
||||
orgTwoId: {
|
||||
type: "organization",
|
||||
key: "orgTwoEncKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("AddKeyTypeToOrgKeysMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: AddKeyTypeToOrgKeysMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(migrateExampleJSON());
|
||||
sut = new AddKeyTypeToOrgKeysMigrator(4, 5);
|
||||
});
|
||||
|
||||
it("should add organization type to organization keys", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: {
|
||||
type: "organization",
|
||||
key: "orgOneEncKey",
|
||||
},
|
||||
orgTwoId: {
|
||||
type: "organization",
|
||||
key: "orgTwoEncKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should update version", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 5,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackExampleJSON());
|
||||
sut = new AddKeyTypeToOrgKeysMigrator(4, 5);
|
||||
});
|
||||
|
||||
it("should remove type from orgainzation keys", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: "orgOneEncKey",
|
||||
orgTwoId: "orgTwoEncKey",
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should update version down", async () => {
|
||||
await sut.updateVersion(helper, "down");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 4,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Direction, Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = { keys?: { organizationKeys?: { encrypted: Record<string, string> } } };
|
||||
type NewAccountType = {
|
||||
keys?: {
|
||||
organizationKeys?: { encrypted: Record<string, { type: "organization"; key: string }> };
|
||||
};
|
||||
};
|
||||
|
||||
export class AddKeyTypeToOrgKeysMigrator extends Migrator<4, 5> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts();
|
||||
|
||||
async function updateOrgKey(userId: string, account: ExpectedAccountType) {
|
||||
const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted;
|
||||
if (encryptedOrgKeys == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrgKeys: Record<string, { type: "organization"; key: string }> = {};
|
||||
|
||||
Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => {
|
||||
newOrgKeys[orgId] = {
|
||||
type: "organization",
|
||||
key: encKey,
|
||||
};
|
||||
});
|
||||
(account as any).keys.organizationKeys.encrypted = newOrgKeys;
|
||||
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
|
||||
Promise.all(accounts.map(({ userId, account }) => updateOrgKey(userId, account)));
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts();
|
||||
|
||||
async function updateOrgKey(userId: string, account: NewAccountType) {
|
||||
const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted;
|
||||
if (encryptedOrgKeys == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrgKeys: Record<string, string> = {};
|
||||
|
||||
Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => {
|
||||
newOrgKeys[orgId] = encKey.key;
|
||||
});
|
||||
(account as any).keys.organizationKeys.encrypted = newOrgKeys;
|
||||
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
|
||||
Promise.all(accounts.map(async ({ userId, account }) => updateOrgKey(userId, account)));
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
|
||||
// it is nested inside a global object.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { RemoveLegacyEtmKeyMigrator } from "./6-remove-legacy-etm-key";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 5,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
keys: {
|
||||
legacyEtmKey: "legacyEtmKey",
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
keys: {
|
||||
legacyEtmKey: "legacyEtmKey",
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("RemoveLegacyEtmKeyMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: RemoveLegacyEtmKeyMigrator;
|
||||
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON());
|
||||
sut = new RemoveLegacyEtmKeyMigrator(5, 6);
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
it("should remove legacyEtmKey from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
keys: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
it("should throw", async () => {
|
||||
await expect(sut.rollback(helper)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVersion", () => {
|
||||
it("should update version up", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 6,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = { keys?: { legacyEtmKey?: string } };
|
||||
|
||||
export class RemoveLegacyEtmKeyMigrator extends Migrator<5, 6> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function updateAccount(userId: string, account: ExpectedAccountType) {
|
||||
if (account?.keys?.legacyEtmKey) {
|
||||
delete account.keys.legacyEtmKey;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => updateAccount(userId, account)));
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
throw IRREVERSIBLE;
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
|
||||
// it is nested inside a global object.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { MockProxy, any, matches } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { MoveBiometricAutoPromptToAccount } from "./7-move-biometric-auto-prompt-to-account";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 6,
|
||||
noAutoPromptBiometrics: true,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("RemoveLegacyEtmKeyMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: MoveBiometricAutoPromptToAccount;
|
||||
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON());
|
||||
sut = new MoveBiometricAutoPromptToAccount(6, 7);
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
it("should remove noAutoPromptBiometrics from global", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
otherStuff: "otherStuff1",
|
||||
stateVersion: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it("should set disableAutoBiometricsPrompt to true on all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
settings: {
|
||||
disableAutoBiometricsPrompt: true,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
|
||||
settings: {
|
||||
disableAutoBiometricsPrompt: true,
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not set disableAutoBiometricsPrompt to true on accounts if noAutoPromptBiometrics is false", async () => {
|
||||
const json = exampleJSON();
|
||||
json.global.noAutoPromptBiometrics = false;
|
||||
helper = mockMigrationHelper(json);
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).not.toHaveBeenCalledWith(
|
||||
matches((s) => s != "global"),
|
||||
any()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
it("should throw", async () => {
|
||||
await expect(sut.rollback(helper)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVersion", () => {
|
||||
it("should update version up", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith(
|
||||
"global",
|
||||
Object.assign({}, exampleJSON().global, {
|
||||
stateVersion: 7,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = { settings?: { disableAutoBiometricsPrompt?: boolean } };
|
||||
|
||||
export class MoveBiometricAutoPromptToAccount extends Migrator<6, 7> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const global = await helper.get<{ noAutoPromptBiometrics?: boolean }>("global");
|
||||
const noAutoPromptBiometrics = global?.noAutoPromptBiometrics ?? false;
|
||||
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function updateAccount(userId: string, account: ExpectedAccountType) {
|
||||
if (account == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (noAutoPromptBiometrics) {
|
||||
account.settings = Object.assign(account?.settings ?? {}, {
|
||||
disableAutoBiometricsPrompt: true,
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
delete global.noAutoPromptBiometrics;
|
||||
|
||||
await Promise.all([
|
||||
...accounts.map(({ userId, account }) => updateAccount(userId, account)),
|
||||
helper.set("global", global),
|
||||
]);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
throw IRREVERSIBLE;
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
|
||||
// it is nested inside a global object.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { MoveStateVersionMigrator } from "./8-move-state-version";
|
||||
|
||||
function migrateExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 6,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
stateVersion: 7,
|
||||
otherStuff: "otherStuff2",
|
||||
};
|
||||
}
|
||||
|
||||
describe("moveStateVersion", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: MoveStateVersionMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(migrateExampleJSON());
|
||||
sut = new MoveStateVersionMigrator(7, 8);
|
||||
});
|
||||
|
||||
it("should move state version to root", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("stateVersion", 6);
|
||||
});
|
||||
|
||||
it("should remove state version from global", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw if state version not found", async () => {
|
||||
helper.get.mockReturnValue({ otherStuff: "otherStuff1" } as any);
|
||||
await expect(sut.migrate(helper)).rejects.toThrow(
|
||||
"Migration failed, state version not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("should update version up", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("stateVersion", 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackExampleJSON());
|
||||
sut = new MoveStateVersionMigrator(7, 8);
|
||||
});
|
||||
|
||||
it("should move state version to global", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 7,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("stateVersion", undefined);
|
||||
});
|
||||
|
||||
it("should update version down", async () => {
|
||||
await sut.updateVersion(helper, "down");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 7,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { JsonObject } from "type-fest";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Direction, Migrator } from "../migrator";
|
||||
|
||||
export class MoveStateVersionMigrator extends Migrator<7, 8> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const global = await helper.get<{ stateVersion: number }>("global");
|
||||
if (global.stateVersion) {
|
||||
await helper.set("stateVersion", global.stateVersion);
|
||||
delete global.stateVersion;
|
||||
await helper.set("global", global);
|
||||
} else {
|
||||
throw new Error("Migration failed, state version not found");
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const version = await helper.get<number>("stateVersion");
|
||||
const global = await helper.get<JsonObject>("global");
|
||||
await helper.set("global", { ...global, stateVersion: version });
|
||||
await helper.set("stateVersion", undefined);
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but this migration moves
|
||||
// it from a `global` object to root.This makes for unique rollback versioning.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
if (direction === "up") {
|
||||
await helper.set("stateVersion", endVersion);
|
||||
} else {
|
||||
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MIN_VERSION } from "../migrate";
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { MinVersionMigrator } from "./min-version";
|
||||
|
||||
describe("MinVersionMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: MinVersionMigrator;
|
||||
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(null);
|
||||
sut = new MinVersionMigrator();
|
||||
});
|
||||
|
||||
describe("shouldMigrate", () => {
|
||||
it("should return true if current version is less than min version", async () => {
|
||||
helper.currentVersion = MIN_VERSION - 1;
|
||||
expect(await sut.shouldMigrate(helper)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if current version is greater than min version", async () => {
|
||||
helper.currentVersion = MIN_VERSION + 1;
|
||||
expect(await sut.shouldMigrate(helper)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
libs/common/src/state-migrations/migrations/min-version.ts
Normal file
26
libs/common/src/state-migrations/migrations/min-version.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { MinVersion, MIN_VERSION } from "../migrate";
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { IRREVERSIBLE, Migrator } from "../migrator";
|
||||
|
||||
export function minVersionError(current: number) {
|
||||
return `Your local data is too old to be migrated. Your current state version is ${current}, but minimum version is ${MIN_VERSION}.`;
|
||||
}
|
||||
|
||||
export class MinVersionMigrator extends Migrator<0, MinVersion> {
|
||||
constructor() {
|
||||
super(0, MIN_VERSION);
|
||||
}
|
||||
|
||||
// Overrides the default implementation to catch any version that may be passed in.
|
||||
override shouldMigrate(helper: MigrationHelper): Promise<boolean> {
|
||||
return Promise.resolve(helper.currentVersion < MIN_VERSION);
|
||||
}
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
if (helper.currentVersion < MIN_VERSION) {
|
||||
throw new Error(minVersionError(helper.currentVersion));
|
||||
}
|
||||
}
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
throw IRREVERSIBLE;
|
||||
}
|
||||
}
|
||||
75
libs/common/src/state-migrations/migrator.spec.ts
Normal file
75
libs/common/src/state-migrations/migrator.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations
|
||||
import { AbstractStorageService } from "../platform/abstractions/storage.service";
|
||||
|
||||
import { MigrationHelper } from "./migration-helper";
|
||||
import { Migrator } from "./migrator";
|
||||
|
||||
describe("migrator default methods", () => {
|
||||
class TestMigrator extends Migrator<0, 1> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
await helper.set("test", "test");
|
||||
}
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
await helper.set("test", "rollback");
|
||||
}
|
||||
}
|
||||
|
||||
let storage: MockProxy<AbstractStorageService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let helper: MigrationHelper;
|
||||
let sut: TestMigrator;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = mock();
|
||||
logService = mock();
|
||||
helper = new MigrationHelper(0, storage, logService);
|
||||
sut = new TestMigrator(0, 1);
|
||||
});
|
||||
|
||||
describe("shouldMigrate", () => {
|
||||
describe("up", () => {
|
||||
it("should return true if the current version equals the from version", async () => {
|
||||
expect(await sut.shouldMigrate(helper, "up")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the current version does not equal the from version", async () => {
|
||||
helper.currentVersion = 1;
|
||||
expect(await sut.shouldMigrate(helper, "up")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("down", () => {
|
||||
it("should return true if the current version equals the to version", async () => {
|
||||
helper.currentVersion = 1;
|
||||
expect(await sut.shouldMigrate(helper, "down")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the current version does not equal the to version", async () => {
|
||||
expect(await sut.shouldMigrate(helper, "down")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVersion", () => {
|
||||
describe("up", () => {
|
||||
it("should update the version", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
expect(storage.save).toBeCalledWith("stateVersion", 1);
|
||||
expect(helper.currentVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("down", () => {
|
||||
it("should update the version", async () => {
|
||||
helper.currentVersion = 1;
|
||||
await sut.updateVersion(helper, "down");
|
||||
expect(storage.save).toBeCalledWith("stateVersion", 0);
|
||||
expect(helper.currentVersion).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
40
libs/common/src/state-migrations/migrator.ts
Normal file
40
libs/common/src/state-migrations/migrator.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NonNegativeInteger } from "type-fest";
|
||||
|
||||
import { MigrationHelper } from "./migration-helper";
|
||||
|
||||
export const IRREVERSIBLE = new Error("Irreversible migration");
|
||||
|
||||
export type VersionFrom<T> = T extends Migrator<infer TFrom, number>
|
||||
? TFrom extends NonNegativeInteger<TFrom>
|
||||
? TFrom
|
||||
: never
|
||||
: never;
|
||||
export type VersionTo<T> = T extends Migrator<number, infer TTo>
|
||||
? TTo extends NonNegativeInteger<TTo>
|
||||
? TTo
|
||||
: never
|
||||
: never;
|
||||
export type Direction = "up" | "down";
|
||||
|
||||
export abstract class Migrator<TFrom extends number, TTo extends number> {
|
||||
constructor(public fromVersion: TFrom, public toVersion: TTo) {
|
||||
if (fromVersion == null || toVersion == null) {
|
||||
throw new Error("Invalid migration");
|
||||
}
|
||||
if (fromVersion > toVersion) {
|
||||
throw new Error("Invalid migration");
|
||||
}
|
||||
}
|
||||
|
||||
shouldMigrate(helper: MigrationHelper, direction: Direction): Promise<boolean> {
|
||||
const startVersion = direction === "up" ? this.fromVersion : this.toVersion;
|
||||
return Promise.resolve(helper.currentVersion === startVersion);
|
||||
}
|
||||
abstract migrate(helper: MigrationHelper): Promise<void>;
|
||||
abstract rollback(helper: MigrationHelper): Promise<void>;
|
||||
async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
await helper.set("stateVersion", endVersion);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
notFoundText="{{ 'multiSelectNotFound' | i18n }}"
|
||||
clearAllText="{{ 'multiSelectClearAll' | i18n }}"
|
||||
[multiple]="true"
|
||||
[selectOnTab]="true"
|
||||
[closeOnSelect]="false"
|
||||
(close)="onDropdownClosed()"
|
||||
[disabled]="disabled"
|
||||
|
||||
@@ -75,12 +75,6 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.select.isOpen && event.key === "Enter" && !hasModifierKey(event)) {
|
||||
this.select.close();
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.select.isOpen && event.key === "Escape" && !hasModifierKey(event)) {
|
||||
this.selectedItems = [];
|
||||
this.select.close();
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
|
||||
--color-text-main: 253 246 227;
|
||||
--color-text-muted: 147 161 161;
|
||||
--color-text-contrast: 0 43 54;
|
||||
--color-text-contrast: 0 0 0;
|
||||
--color-text-alt2: 255 255 255;
|
||||
--color-text-code: 240 141 199;
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ $primary: #175ddc;
|
||||
$primary-accent: #1252a3;
|
||||
$secondary: #ced4da;
|
||||
$secondary-alt: #1a3b66;
|
||||
$success: #00a65a;
|
||||
$success: #017e45;
|
||||
$info: #555555;
|
||||
$warning: #bf7e16;
|
||||
$danger: #dd4b39;
|
||||
$warning: #8b6609;
|
||||
$danger: #c83522;
|
||||
$white: #ffffff;
|
||||
|
||||
// Bootstrap Variable Overrides
|
||||
@@ -85,7 +85,7 @@ $mfaTypes: 0, 2, 3, 4, 6;
|
||||
// Theme Variables
|
||||
// Light
|
||||
|
||||
$lightDangerHover: #c43421;
|
||||
$lightDangerHover: #c83522;
|
||||
$lightInputColor: #465057;
|
||||
$lightInputPlaceholderColor: #b6b8b8;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user