1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-21 10:43:35 +00:00

refactor: introduce @bitwarden/state and other common libs (#15772)

* refactor: introduce @bitwarden/serialization

* refactor: introduce @bitwarden/guid

* refactor: introduce @bitwaren/client-type

* refactor: introduce @bitwarden/core-test-utils

* refactor: introduce @bitwarden/state and @bitwarden/state-test-utils

Creates initial project structure for centralized application state management. Part of modularization effort to extract state code from common.

* Added state provider documentation to README.

* Changed callouts to Github format.

* Fixed linting on file name.

* Forced git to accept rename

---------

Co-authored-by: Todd Martin <tmartin@bitwarden.com>
This commit is contained in:
Addison Beck
2025-08-04 11:01:28 -04:00
committed by GitHub
parent 5833ed459b
commit 361f7e3447
306 changed files with 4491 additions and 2702 deletions

View File

@@ -0,0 +1,161 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { EverHadUserKeyMigrator } from "./10-move-ever-had-user-key-to-state-providers";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
profile: {
everHadUserKey: false,
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
profile: {
everHadUserKey: true,
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_c493ed01-4e08-4e88-abc7-332f380ca760_crypto_everHadUserKey": false,
"user_23e61a5f-2ece-4f5e-b499-f0bc489482a9_crypto_everHadUserKey": true,
"user_fd005ea6-a16a-45ef-ba4a-a194269bfd73_crypto_everHadUserKey": false,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: [
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
],
"c493ed01-4e08-4e88-abc7-332f380ca760": {
profile: {
everHadUserKey: false,
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
profile: {
everHadUserKey: true,
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("EverHadUserKeyMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: EverHadUserKeyMigrator;
const keyDefinitionLike = {
key: "everHadUserKey",
stateDefinition: {
name: "crypto",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 9);
sut = new EverHadUserKeyMigrator(9, 10);
});
it("should remove everHadUserKey from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
profile: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should set everHadUserKey provider value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith(
"c493ed01-4e08-4e88-abc7-332f380ca760",
keyDefinitionLike,
false,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
keyDefinitionLike,
true,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
keyDefinitionLike,
false,
);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 10);
sut = new EverHadUserKeyMigrator(9, 10);
});
it.each([
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
profile: {
everHadUserKey: false,
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
profile: {
everHadUserKey: true,
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("fd005ea6-a16a-45ef-ba4a-a194269bfd73", any());
});
});
});

View File

@@ -0,0 +1,48 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
profile?: {
everHadUserKey?: boolean;
};
};
const USER_EVER_HAD_USER_KEY: KeyDefinitionLike = {
key: "everHadUserKey",
stateDefinition: {
name: "crypto",
},
};
export class EverHadUserKeyMigrator extends Migrator<9, 10> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.profile?.everHadUserKey;
await helper.setToUser(userId, USER_EVER_HAD_USER_KEY, value ?? false);
if (value != null) {
delete account.profile.everHadUserKey;
}
await helper.set(userId, account);
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, USER_EVER_HAD_USER_KEY);
if (account) {
account.profile = Object.assign(account.profile ?? {}, {
everHadUserKey: value,
});
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_EVER_HAD_USER_KEY, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,163 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { OrganizationKeyMigrator } from "./11-move-org-keys-to-state-providers";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
keys: {
organizationKeys: {
encrypted: {
"org-id-1": {
type: "organization",
key: "org-key-1",
},
"org-id-2": {
type: "provider",
key: "org-key-2",
providerId: "provider-id-2",
},
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_crypto_organizationKeys": {
"org-id-1": {
type: "organization",
key: "org-key-1",
},
"org-id-2": {
type: "provider",
key: "org-key-2",
providerId: "provider-id-2",
},
},
"user_user-2_crypto_organizationKeys": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
keys: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("OrganizationKeysMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: OrganizationKeyMigrator;
const keyDefinitionLike = {
key: "organizationKeys",
stateDefinition: {
name: "crypto",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 10);
sut = new OrganizationKeyMigrator(10, 11);
});
it("should remove organizationKeys from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
keys: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set organizationKeys value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(1);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"org-id-1": {
type: "organization",
key: "org-key-1",
},
"org-id-2": {
type: "provider",
key: "org-key-2",
providerId: "provider-id-2",
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 11);
sut = new OrganizationKeyMigrator(10, 11);
});
it.each(["user-1", "user-2", "user-3"])("should null out new values %s", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
keys: {
organizationKeys: {
encrypted: {
"org-id-1": {
type: "organization",
key: "org-key-1",
},
"org-id-2": {
type: "provider",
key: "org-key-2",
providerId: "provider-id-2",
},
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,61 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type OrgKeyDataType = {
type: "organization" | "provider";
key: string;
providerId?: string;
};
type ExpectedAccountType = {
keys?: {
organizationKeys?: {
encrypted?: Record<string, OrgKeyDataType>;
};
};
};
const USER_ENCRYPTED_ORGANIZATION_KEYS: KeyDefinitionLike = {
key: "organizationKeys",
stateDefinition: {
name: "crypto",
},
};
export class OrganizationKeyMigrator extends Migrator<10, 11> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.keys?.organizationKeys?.encrypted;
if (value != null) {
await helper.setToUser(userId, USER_ENCRYPTED_ORGANIZATION_KEYS, value);
delete account.keys.organizationKeys;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser<Record<string, OrgKeyDataType>>(
userId,
USER_ENCRYPTED_ORGANIZATION_KEYS,
);
if (account && value) {
account.keys = Object.assign(account.keys ?? {}, {
organizationKeys: {
encrypted: value,
},
});
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_ENCRYPTED_ORGANIZATION_KEYS, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,157 @@
import { runMigrator } from "../migration-helper.spec";
import { MoveEnvironmentStateToProviders } from "./12-move-environment-state-to-providers";
describe("MoveEnvironmentStateToProviders", () => {
const migrator = new MoveEnvironmentStateToProviders(11, 12);
it("can migrate all data", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user1", "user2"] as const,
global: {
region: "US",
environmentUrls: {
base: "example.com",
},
extra: "data",
},
user1: {
extra: "data",
settings: {
extra: "data",
region: "US",
environmentUrls: {
base: "example.com",
},
},
},
user2: {
extra: "data",
settings: {
region: "EU",
environmentUrls: {
base: "other.example.com",
},
extra: "data",
},
},
extra: "data",
});
expect(output).toEqual({
authenticatedAccounts: ["user1", "user2"],
global: {
extra: "data",
},
global_environment_region: "US",
global_environment_urls: {
base: "example.com",
},
user1: {
extra: "data",
settings: {
extra: "data",
},
},
user2: {
extra: "data",
settings: {
extra: "data",
},
},
extra: "data",
user_user1_environment_region: "US",
user_user2_environment_region: "EU",
user_user1_environment_urls: {
base: "example.com",
},
user_user2_environment_urls: {
base: "other.example.com",
},
});
});
it("handles missing parts", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user1", "user2"],
global: {
extra: "data",
},
user1: {
extra: "data",
settings: {
extra: "data",
},
},
user2: null,
});
expect(output).toEqual({
authenticatedAccounts: ["user1", "user2"],
global: {
extra: "data",
},
user1: {
extra: "data",
settings: {
extra: "data",
},
},
user2: null,
});
});
it("can migrate only global data", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: [] as const,
global: {
region: "Self-Hosted",
},
});
expect(output).toEqual({
authenticatedAccounts: [],
global_environment_region: "Self-Hosted",
global: {},
});
});
it("can migrate only user state", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user1"] as const,
global: null,
user1: {
settings: {
region: "Self-Hosted",
environmentUrls: {
base: "some-base-url",
api: "some-api-url",
identity: "some-identity-url",
icons: "some-icons-url",
notifications: "some-notifications-url",
events: "some-events-url",
webVault: "some-webVault-url",
keyConnector: "some-keyConnector-url",
},
},
},
});
expect(output).toEqual({
authenticatedAccounts: ["user1"] as const,
global: null,
user1: { settings: {} },
user_user1_environment_region: "Self-Hosted",
user_user1_environment_urls: {
base: "some-base-url",
api: "some-api-url",
identity: "some-identity-url",
icons: "some-icons-url",
notifications: "some-notifications-url",
events: "some-events-url",
webVault: "some-webVault-url",
keyConnector: "some-keyConnector-url",
},
});
});
});

View File

@@ -0,0 +1,132 @@
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { Migrator } from "../migrator";
type EnvironmentUrls = Record<string, string>;
type ExpectedAccountType = {
settings?: { region?: string; environmentUrls?: EnvironmentUrls };
};
type ExpectedGlobalType = { region?: string; environmentUrls?: EnvironmentUrls };
const ENVIRONMENT_STATE: StateDefinitionLike = { name: "environment" };
const REGION_KEY: KeyDefinitionLike = { key: "region", stateDefinition: ENVIRONMENT_STATE };
const URLS_KEY: KeyDefinitionLike = { key: "urls", stateDefinition: ENVIRONMENT_STATE };
export class MoveEnvironmentStateToProviders extends Migrator<11, 12> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyGlobal = await helper.get<ExpectedGlobalType>("global");
// Move global data
if (legacyGlobal?.region != null) {
await helper.setToGlobal(REGION_KEY, legacyGlobal.region);
}
if (legacyGlobal?.environmentUrls != null) {
await helper.setToGlobal(URLS_KEY, legacyGlobal.environmentUrls);
}
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(
legacyAccounts.map(async ({ userId, account }) => {
// Move account data
if (account?.settings?.region != null) {
await helper.setToUser(userId, REGION_KEY, account.settings.region);
}
if (account?.settings?.environmentUrls != null) {
await helper.setToUser(userId, URLS_KEY, account.settings.environmentUrls);
}
// Delete old account data
delete account?.settings?.region;
delete account?.settings?.environmentUrls;
await helper.set(userId, account);
}),
);
// Delete legacy global data
delete legacyGlobal?.region;
delete legacyGlobal?.environmentUrls;
await helper.set("global", legacyGlobal);
}
async rollback(helper: MigrationHelper): Promise<void> {
let legacyGlobal = await helper.get<ExpectedGlobalType>("global");
let updatedLegacyGlobal = false;
const globalRegion = await helper.getFromGlobal<string>(REGION_KEY);
if (globalRegion) {
if (!legacyGlobal) {
legacyGlobal = {};
}
updatedLegacyGlobal = true;
legacyGlobal.region = globalRegion;
await helper.setToGlobal(REGION_KEY, null);
}
const globalUrls = await helper.getFromGlobal<EnvironmentUrls>(URLS_KEY);
if (globalUrls) {
if (!legacyGlobal) {
legacyGlobal = {};
}
updatedLegacyGlobal = true;
legacyGlobal.environmentUrls = globalUrls;
await helper.setToGlobal(URLS_KEY, null);
}
if (updatedLegacyGlobal) {
await helper.set("global", legacyGlobal);
}
async function rollbackUser(userId: string, account: ExpectedAccountType) {
let updatedAccount = false;
const userRegion = await helper.getFromUser<string>(userId, REGION_KEY);
if (userRegion) {
if (!account) {
account = {};
}
if (!account.settings) {
account.settings = {};
}
updatedAccount = true;
account.settings.region = userRegion;
await helper.setToUser(userId, REGION_KEY, null);
}
const userUrls = await helper.getFromUser<EnvironmentUrls>(userId, URLS_KEY);
if (userUrls) {
if (!account) {
account = {};
}
if (!account.settings) {
account.settings = {};
}
updatedAccount = true;
account.settings.environmentUrls = userUrls;
await helper.setToUser(userId, URLS_KEY, null);
}
if (updatedAccount) {
await helper.set(userId, account);
}
}
const accounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
}
}

View File

@@ -0,0 +1,135 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { ProviderKeyMigrator } from "./13-move-provider-keys-to-state-providers";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
keys: {
providerKeys: {
encrypted: {
"provider-id-1": "provider-key-1",
"provider-id-2": "provider-key-2",
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_crypto_providerKeys": {
"provider-id-1": "provider-key-1",
"provider-id-2": "provider-key-2",
},
"user_user-2_crypto_providerKeys": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
keys: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("ProviderKeysMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: ProviderKeyMigrator;
const keyDefinitionLike = {
key: "providerKeys",
stateDefinition: {
name: "crypto",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 12);
sut = new ProviderKeyMigrator(12, 13);
});
it("should remove providerKeys from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
keys: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set providerKeys value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(1);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"provider-id-1": "provider-key-1",
"provider-id-2": "provider-key-2",
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 13);
sut = new ProviderKeyMigrator(12, 13);
});
it.each(["user-1", "user-2", "user-3"])("should null out new values %s", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
keys: {
providerKeys: {
encrypted: {
"provider-id-1": "provider-key-1",
"provider-id-2": "provider-key-2",
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,55 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
keys?: {
providerKeys?: {
encrypted?: Record<string, string>; // Record<ProviderId, EncryptedString> where EncryptedString is the ProviderKey encrypted by the UserKey.
};
};
};
const USER_ENCRYPTED_PROVIDER_KEYS: KeyDefinitionLike = {
key: "providerKeys",
stateDefinition: {
name: "crypto",
},
};
export class ProviderKeyMigrator extends Migrator<12, 13> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.keys?.providerKeys?.encrypted;
if (value != null) {
await helper.setToUser(userId, USER_ENCRYPTED_PROVIDER_KEYS, value);
delete account.keys.providerKeys;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser<Record<string, string>>(
userId,
USER_ENCRYPTED_PROVIDER_KEYS,
);
if (account && value) {
account.keys = Object.assign(account.keys ?? {}, {
providerKeys: {
encrypted: value,
},
});
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_ENCRYPTED_PROVIDER_KEYS, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,123 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
MoveBiometricClientKeyHalfToStateProviders,
CLIENT_KEY_HALF,
} from "./14-move-biometric-client-key-half-state-to-providers";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
keys: {
biometricEncryptionClientKeyHalf: "user1-key-half",
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_biometricSettings_clientKeyHalf": "user1-key-half",
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
keys: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("DesktopBiometricState migrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MoveBiometricClientKeyHalfToStateProviders;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 13);
sut = new MoveBiometricClientKeyHalfToStateProviders(13, 14);
});
it("should remove biometricEncryptionClientKeyHalf from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
keys: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set biometricEncryptionClientKeyHalf value for account that have it", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", CLIENT_KEY_HALF, "user1-key-half");
});
it("should not call extra setToUser", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(1);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 14);
sut = new MoveBiometricClientKeyHalfToStateProviders(13, 14);
});
it("should null out new values", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", CLIENT_KEY_HALF, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
keys: {
biometricEncryptionClientKeyHalf: "user1-key-half",
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it.each(["user-2", "user-3"])(
"should not try to restore values to missing accounts",
async (userId) => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith(userId, any());
},
);
});
});

View File

@@ -0,0 +1,65 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
settings?: {
disableAutoBiometricsPrompt?: boolean;
biometricUnlock?: boolean;
dismissedBiometricRequirePasswordOnStartCallout?: boolean;
};
keys?: { biometricEncryptionClientKeyHalf?: string };
};
// Biometric text, no auto prompt text, fingerprint validated, and prompt cancelled are refreshed on every app start, so we don't need to migrate them
export const CLIENT_KEY_HALF: KeyDefinitionLike = {
key: "clientKeyHalf",
stateDefinition: { name: "biometricSettings" },
};
export class MoveBiometricClientKeyHalfToStateProviders extends Migrator<13, 14> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(
legacyAccounts.map(async ({ userId, account }) => {
// Move account data
if (account?.keys?.biometricEncryptionClientKeyHalf != null) {
await helper.setToUser(
userId,
CLIENT_KEY_HALF,
account.keys.biometricEncryptionClientKeyHalf,
);
// Delete old account data
delete account?.keys?.biometricEncryptionClientKeyHalf;
await helper.set(userId, account);
}
}),
);
}
async rollback(helper: MigrationHelper): Promise<void> {
async function rollbackUser(userId: string, account: ExpectedAccountType) {
let updatedAccount = false;
const userKeyHalf = await helper.getFromUser<string>(userId, CLIENT_KEY_HALF);
if (userKeyHalf) {
account ??= {};
account.keys ??= {};
updatedAccount = true;
account.keys.biometricEncryptionClientKeyHalf = userKeyHalf;
await helper.setToUser(userId, CLIENT_KEY_HALF, null);
}
if (updatedAccount) {
await helper.set(userId, account);
}
}
const accounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
}
}

View File

@@ -0,0 +1,163 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { FolderMigrator } from "./15-move-folder-state-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
folders: {
encrypted: {
"folder-id-1": {
id: "folder-id-1",
name: "folder-name-1",
revisionDate: "folder-revision-date-1",
},
"folder-id-2": {
id: "folder-id-2",
name: "folder-name-2",
revisionDate: "folder-revision-date-2",
},
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_folder_folders": {
"folder-id-1": {
id: "folder-id-1",
name: "folder-name-1",
revisionDate: "folder-revision-date-1",
},
"folder-id-2": {
id: "folder-id-2",
name: "folder-name-2",
revisionDate: "folder-revision-date-2",
},
},
"user_user-2_folder_folders": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("FolderMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: FolderMigrator;
const keyDefinitionLike = {
key: "folders",
stateDefinition: {
name: "folder",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 14);
sut = new FolderMigrator(14, 15);
});
it("should remove folders from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set folders value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"folder-id-1": {
id: "folder-id-1",
name: "folder-name-1",
revisionDate: "folder-revision-date-1",
},
"folder-id-2": {
id: "folder-id-2",
name: "folder-name-2",
revisionDate: "folder-revision-date-2",
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 15);
sut = new FolderMigrator(14, 15);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
folders: {
encrypted: {
"folder-id-1": {
id: "folder-id-1",
name: "folder-name-1",
revisionDate: "folder-revision-date-1",
},
"folder-id-2": {
id: "folder-id-2",
name: "folder-name-2",
revisionDate: "folder-revision-date-2",
},
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,59 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type FolderDataType = {
id: string;
name: string;
revisionDate: string;
};
type ExpectedAccountType = {
data?: {
folders?: {
encrypted?: Record<string, FolderDataType>;
};
};
};
const USER_ENCRYPTED_FOLDERS: KeyDefinitionLike = {
key: "folders",
stateDefinition: {
name: "folder",
},
};
export class FolderMigrator extends Migrator<14, 15> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.data?.folders?.encrypted;
if (value != null) {
await helper.setToUser(userId, USER_ENCRYPTED_FOLDERS, value);
delete account.data.folders;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, USER_ENCRYPTED_FOLDERS);
if (account) {
account.data = Object.assign(account.data ?? {}, {
folders: {
encrypted: value,
},
});
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_ENCRYPTED_FOLDERS, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,112 @@
import { any, MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { LastSyncMigrator } from "./16-move-last-sync-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
profile: {
lastSync: "2024-01-24T00:00:00.000Z",
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_sync_lastSync": "2024-01-24T00:00:00.000Z",
"user_user-2_sync_lastSync": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
profile: {
lastSync: "2024-01-24T00:00:00.000Z",
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("LastSyncMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: LastSyncMigrator;
const keyDefinitionLike = {
key: "lastSync",
stateDefinition: {
name: "sync",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 15);
sut = new LastSyncMigrator(15, 16);
});
it("should remove lastSync from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should set lastSync provider value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
keyDefinitionLike,
"2024-01-24T00:00:00.000Z",
);
expect(helper.setToUser).toHaveBeenCalledWith("user-2", keyDefinitionLike, null);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 16);
sut = new LastSyncMigrator(15, 16);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add lastSync back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
profile: {
lastSync: "2024-01-24T00:00:00.000Z",
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-2", any());
});
});
});

View File

@@ -0,0 +1,49 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
profile?: {
lastSync?: string;
};
};
const LAST_SYNC_KEY: KeyDefinitionLike = {
key: "lastSync",
stateDefinition: {
name: "sync",
},
};
export class LastSyncMigrator extends Migrator<15, 16> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.profile?.lastSync;
await helper.setToUser(userId, LAST_SYNC_KEY, value ?? null);
if (value != null) {
delete account.profile.lastSync;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, LAST_SYNC_KEY);
if (account) {
account.profile = Object.assign(account.profile ?? {}, {
lastSync: value,
});
await helper.set(userId, account);
}
await helper.setToUser(userId, LAST_SYNC_KEY, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,84 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { EnablePasskeysMigrator } from "./17-move-enable-passkeys-to-state-providers";
function exampleJSON() {
return {
global: {
enablePasskeys: true,
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
global_vaultSettings_enablePasskeys: true,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("EnablePasskeysMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: EnablePasskeysMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 16);
sut = new EnablePasskeysMigrator(16, 17);
});
it("should remove enablePasskeys from global", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 17);
sut = new EnablePasskeysMigrator(16, 17);
});
it("should move enablePasskeys to global", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("global", {
enablePasskeys: true,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,36 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedGlobalType = {
enablePasskeys?: boolean;
};
const USER_ENABLE_PASSKEYS: KeyDefinitionLike = {
key: "enablePasskeys",
stateDefinition: {
name: "vaultSettings",
},
};
export class EnablePasskeysMigrator extends Migrator<16, 17> {
async migrate(helper: MigrationHelper): Promise<void> {
const global = await helper.get<ExpectedGlobalType>("global");
if (global?.enablePasskeys != null) {
await helper.setToGlobal(USER_ENABLE_PASSKEYS, global.enablePasskeys);
delete global?.enablePasskeys;
await helper.set("global", global);
}
}
async rollback(helper: MigrationHelper): Promise<void> {
let global = await helper.get<ExpectedGlobalType>("global");
const globalEnablePasskeys = await helper.getFromGlobal<boolean>(USER_ENABLE_PASSKEYS);
if (globalEnablePasskeys != null) {
global = Object.assign(global ?? {}, { enablePasskeys: globalEnablePasskeys });
await helper.set("global", global);
await helper.setToGlobal(USER_ENABLE_PASSKEYS, undefined);
}
}
}

View File

@@ -0,0 +1,228 @@
import { any, MockProxy } from "jest-mock-extended";
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { AutofillSettingsKeyMigrator } from "./18-move-autofill-settings-to-state-providers";
const AutofillOverlayVisibility = {
Off: 0,
OnButtonClick: 1,
OnFieldFocus: 2,
} as const;
function exampleJSON() {
return {
global: {
autoFillOverlayVisibility: AutofillOverlayVisibility.OnButtonClick,
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
autoFillOnPageLoadDefault: true,
enableAutoFillOnPageLoad: true,
dismissedAutoFillOnPageLoadCallout: true,
disableAutoTotpCopy: false,
activateAutoFillOnPageLoadFromPolicy: true,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
global_autofillSettingsLocal_inlineMenuVisibility: AutofillOverlayVisibility.OnButtonClick,
"user_user-1_autofillSettings_autoCopyTotp": true,
"user_user-1_autofillSettings_autofillOnPageLoad": true,
"user_user-1_autofillSettings_autofillOnPageLoadCalloutIsDismissed": true,
"user_user-1_autofillSettings_autofillOnPageLoadDefault": true,
"user_user-1_autofillSettingsLocal_activateAutofillOnPageLoadFromPolicy": true,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
const autofillSettingsStateDefinition: {
stateDefinition: StateDefinitionLike;
} = {
stateDefinition: {
name: "autofillSettings",
},
};
describe("AutofillSettingsKeyMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: AutofillSettingsKeyMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 17);
sut = new AutofillSettingsKeyMigrator(17, 18);
});
it("should remove autofill settings from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
});
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set autofill setting values for each account", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{
stateDefinition: {
name: "autofillSettingsLocal",
},
key: "inlineMenuVisibility",
},
1,
);
expect(helper.setToUser).toHaveBeenCalledTimes(5);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadDefault" },
true,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoad" },
true,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadCalloutIsDismissed" },
true,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...autofillSettingsStateDefinition, key: "autoCopyTotp" },
true,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{
stateDefinition: {
name: "autofillSettingsLocal",
},
key: "activateAutofillOnPageLoadFromPolicy",
},
true,
);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 18);
sut = new AutofillSettingsKeyMigrator(17, 18);
});
it("should null out new values for each account", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{
stateDefinition: {
name: "autofillSettingsLocal",
},
key: "inlineMenuVisibility",
},
null,
);
expect(helper.setToUser).toHaveBeenCalledTimes(5);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadDefault" },
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoad" },
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadCalloutIsDismissed" },
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...autofillSettingsStateDefinition, key: "autoCopyTotp" },
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{
stateDefinition: {
name: "autofillSettingsLocal",
},
key: "activateAutofillOnPageLoadFromPolicy",
},
null,
);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("global", {
autoFillOverlayVisibility: 1,
otherStuff: "otherStuff1",
});
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "otherStuff2",
autoFillOnPageLoadDefault: true,
enableAutoFillOnPageLoad: true,
dismissedAutoFillOnPageLoadCallout: true,
disableAutoTotpCopy: false,
activateAutoFillOnPageLoadFromPolicy: true,
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,274 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const AutofillOverlayVisibility = {
Off: 0,
OnButtonClick: 1,
OnFieldFocus: 2,
} as const;
type InlineMenuVisibilitySetting =
(typeof AutofillOverlayVisibility)[keyof typeof AutofillOverlayVisibility];
type ExpectedAccountState = {
settings?: {
autoFillOnPageLoadDefault?: boolean;
enableAutoFillOnPageLoad?: boolean;
dismissedAutoFillOnPageLoadCallout?: boolean;
disableAutoTotpCopy?: boolean;
activateAutoFillOnPageLoadFromPolicy?: InlineMenuVisibilitySetting;
};
};
type ExpectedGlobalState = { autoFillOverlayVisibility?: InlineMenuVisibilitySetting };
const autofillSettingsStateDefinition: {
stateDefinition: StateDefinitionLike;
} = {
stateDefinition: {
name: "autofillSettings",
},
};
export class AutofillSettingsKeyMigrator extends Migrator<17, 18> {
async migrate(helper: MigrationHelper): Promise<void> {
// global state (e.g. "autoFillOverlayVisibility -> inlineMenuVisibility")
const globalState = await helper.get<ExpectedGlobalState>("global");
if (globalState?.autoFillOverlayVisibility != null) {
await helper.setToGlobal(
{
stateDefinition: {
name: "autofillSettingsLocal",
},
key: "inlineMenuVisibility",
},
globalState.autoFillOverlayVisibility,
);
// delete `autoFillOverlayVisibility` from state global
delete globalState.autoFillOverlayVisibility;
await helper.set<ExpectedGlobalState>("global", globalState);
}
// account state (e.g. account settings -> state provider framework keys)
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
// migrate account state
async function migrateAccount(userId: string, account: ExpectedAccountState): Promise<void> {
let updateAccount = false;
const accountSettings = account?.settings;
if (accountSettings?.autoFillOnPageLoadDefault != null) {
await helper.setToUser(
userId,
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadDefault" },
accountSettings.autoFillOnPageLoadDefault,
);
delete account.settings.autoFillOnPageLoadDefault;
updateAccount = true;
}
if (accountSettings?.enableAutoFillOnPageLoad != null) {
await helper.setToUser(
userId,
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoad" },
accountSettings?.enableAutoFillOnPageLoad,
);
delete account.settings.enableAutoFillOnPageLoad;
updateAccount = true;
}
if (accountSettings?.dismissedAutoFillOnPageLoadCallout != null) {
await helper.setToUser(
userId,
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadCalloutIsDismissed" },
accountSettings?.dismissedAutoFillOnPageLoadCallout,
);
delete account.settings.dismissedAutoFillOnPageLoadCallout;
updateAccount = true;
}
if (accountSettings?.disableAutoTotpCopy != null) {
await helper.setToUser(
userId,
{ ...autofillSettingsStateDefinition, key: "autoCopyTotp" },
// invert the value to match the new naming convention
!accountSettings?.disableAutoTotpCopy,
);
delete account.settings.disableAutoTotpCopy;
updateAccount = true;
}
if (accountSettings?.activateAutoFillOnPageLoadFromPolicy != null) {
await helper.setToUser(
userId,
{
stateDefinition: {
name: "autofillSettingsLocal",
},
key: "activateAutofillOnPageLoadFromPolicy",
},
accountSettings?.activateAutoFillOnPageLoadFromPolicy,
);
delete account.settings.activateAutoFillOnPageLoadFromPolicy;
updateAccount = true;
}
if (updateAccount) {
// update the state account settings with the migrated values deleted
await helper.set(userId, account);
}
}
}
async rollback(helper: MigrationHelper): Promise<void> {
// global state (e.g. "inlineMenuVisibility -> autoFillOverlayVisibility")
const globalState = (await helper.get<ExpectedGlobalState>("global")) || {};
const inlineMenuVisibility: InlineMenuVisibilitySetting = await helper.getFromGlobal({
stateDefinition: {
name: "autofillSettingsLocal",
},
key: "inlineMenuVisibility",
});
if (inlineMenuVisibility) {
await helper.set<ExpectedGlobalState>("global", {
...globalState,
autoFillOverlayVisibility: inlineMenuVisibility,
});
// remove the global state provider framework key for `inlineMenuVisibility`
await helper.setToGlobal(
{
stateDefinition: {
name: "autofillSettingsLocal",
},
key: "inlineMenuVisibility",
},
null,
);
}
// account state (e.g. state provider framework keys -> account settings)
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
// rollback account state
async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise<void> {
let updateAccount = false;
let settings = account?.settings || {};
const autoFillOnPageLoadDefault: boolean = await helper.getFromUser(userId, {
...autofillSettingsStateDefinition,
key: "autofillOnPageLoadDefault",
});
const enableAutoFillOnPageLoad: boolean = await helper.getFromUser(userId, {
...autofillSettingsStateDefinition,
key: "autofillOnPageLoad",
});
const dismissedAutoFillOnPageLoadCallout: boolean = await helper.getFromUser(userId, {
...autofillSettingsStateDefinition,
key: "autofillOnPageLoadCalloutIsDismissed",
});
const autoCopyTotp: boolean = await helper.getFromUser(userId, {
...autofillSettingsStateDefinition,
key: "autoCopyTotp",
});
const activateAutoFillOnPageLoadFromPolicy: InlineMenuVisibilitySetting =
await helper.getFromUser(userId, {
stateDefinition: {
name: "autofillSettingsLocal",
},
key: "activateAutofillOnPageLoadFromPolicy",
});
// update new settings and remove the account state provider framework keys for the rolled back values
if (autoFillOnPageLoadDefault != null) {
settings = { ...settings, autoFillOnPageLoadDefault };
await helper.setToUser(
userId,
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadDefault" },
null,
);
updateAccount = true;
}
if (enableAutoFillOnPageLoad != null) {
settings = { ...settings, enableAutoFillOnPageLoad };
await helper.setToUser(
userId,
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoad" },
null,
);
updateAccount = true;
}
if (dismissedAutoFillOnPageLoadCallout != null) {
settings = { ...settings, dismissedAutoFillOnPageLoadCallout };
await helper.setToUser(
userId,
{ ...autofillSettingsStateDefinition, key: "autofillOnPageLoadCalloutIsDismissed" },
null,
);
updateAccount = true;
}
if (autoCopyTotp != null) {
// invert the value to match the new naming convention
settings = { ...settings, disableAutoTotpCopy: !autoCopyTotp };
await helper.setToUser(
userId,
{ ...autofillSettingsStateDefinition, key: "autoCopyTotp" },
null,
);
updateAccount = true;
}
if (activateAutoFillOnPageLoadFromPolicy != null) {
settings = { ...settings, activateAutoFillOnPageLoadFromPolicy };
await helper.setToUser(
userId,
{
stateDefinition: {
name: "autofillSettingsLocal",
},
key: "activateAutofillOnPageLoadFromPolicy",
},
null,
);
updateAccount = true;
}
if (updateAccount) {
// commit updated settings to state
await helper.set(userId, {
...account,
settings,
});
}
}
}
}

View File

@@ -0,0 +1,123 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
REQUIRE_PASSWORD_ON_START,
RequirePasswordOnStartMigrator,
} from "./19-migrate-require-password-on-start";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
requirePasswordOnStart: true,
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_biometricSettings_requirePasswordOnStart": true,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("DesktopBiometricState migrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: RequirePasswordOnStartMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 18);
sut = new RequirePasswordOnStartMigrator(18, 19);
});
it("should remove biometricEncryptionClientKeyHalf from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set biometricEncryptionClientKeyHalf value for account that have it", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", REQUIRE_PASSWORD_ON_START, true);
});
it("should not call extra setToUser", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(1);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 19);
sut = new RequirePasswordOnStartMigrator(18, 19);
});
it("should null out new values", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", REQUIRE_PASSWORD_ON_START, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
requirePasswordOnStart: true,
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it.each(["user-2", "user-3"])(
"should not try to restore values to missing accounts",
async (userId) => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith(userId, any());
},
);
});
});

View File

@@ -0,0 +1,56 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
settings?: {
requirePasswordOnStart?: boolean;
};
};
// Biometric text, no auto prompt text, fingerprint validated, and prompt cancelled are refreshed on every app start, so we don't need to migrate them
export const REQUIRE_PASSWORD_ON_START: KeyDefinitionLike = {
key: "requirePasswordOnStart",
stateDefinition: { name: "biometricSettings" },
};
export class RequirePasswordOnStartMigrator extends Migrator<18, 19> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(
legacyAccounts.map(async ({ userId, account }) => {
// Move account data
if (account?.settings?.requirePasswordOnStart != null) {
await helper.setToUser(
userId,
REQUIRE_PASSWORD_ON_START,
account.settings.requirePasswordOnStart,
);
// Delete old account data
delete account.settings.requirePasswordOnStart;
await helper.set(userId, account);
}
}),
);
}
async rollback(helper: MigrationHelper): Promise<void> {
async function rollbackUser(userId: string, account: ExpectedAccountType) {
const requirePassword = await helper.getFromUser<boolean>(userId, REQUIRE_PASSWORD_ON_START);
if (requirePassword) {
account ??= {};
account.settings ??= {};
account.settings.requirePasswordOnStart = requirePassword;
await helper.setToUser(userId, REQUIRE_PASSWORD_ON_START, null);
await helper.set(userId, account);
}
}
const accounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
}
}

View File

@@ -0,0 +1,127 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { PrivateKeyMigrator } from "./20-move-private-key-to-state-providers";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
keys: {
privateKey: {
encrypted: "user-1-encrypted-private-key",
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_crypto_privateKey": "encrypted-private-key",
"user_user-2_crypto_privateKey": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
keys: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
keys: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("privateKeyMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: PrivateKeyMigrator;
const keyDefinitionLike = {
key: "privateKey",
stateDefinition: {
name: "crypto",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 19);
sut = new PrivateKeyMigrator(19, 20);
});
it("should remove privateKey from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
keys: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set privateKey value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(1);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
keyDefinitionLike,
"user-1-encrypted-private-key",
);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 20);
sut = new PrivateKeyMigrator(19, 20);
});
it.each(["user-1", "user-2", "user-3"])("should null out new values %s", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
keys: {
privateKey: {
encrypted: "encrypted-private-key",
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,55 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
keys?: {
privateKey?: {
encrypted?: string; // EncryptedString
};
};
};
const USER_ENCRYPTED_PRIVATE_KEY: KeyDefinitionLike = {
key: "privateKey",
stateDefinition: {
name: "crypto",
},
};
export class PrivateKeyMigrator extends Migrator<19, 20> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.keys?.privateKey?.encrypted;
if (value != null) {
await helper.setToUser(userId, USER_ENCRYPTED_PRIVATE_KEY, value);
delete account.keys.privateKey;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser<Record<string, string>>(
userId,
USER_ENCRYPTED_PRIVATE_KEY,
);
if (account && value) {
account.keys = Object.assign(account.keys ?? {}, {
privateKey: {
encrypted: value,
},
});
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_ENCRYPTED_PRIVATE_KEY, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,196 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { CollectionMigrator } from "./21-move-collections-state-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
collections: {
encrypted: {
"877fef70-be32-439e-8678-b0d80125653d": {
id: "877fef70-be32-439e-8678-b0d80125653d",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
externalId: "",
readOnly: false,
manage: true,
hidePasswords: false,
},
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
externalId: "",
readOnly: false,
manage: false,
hidePasswords: false,
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_collection_collections": {
"877fef70-be32-439e-8678-b0d80125653d": {
id: "877fef70-be32-439e-8678-b0d80125653d",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
externalId: "",
readOnly: false,
manage: true,
hidePasswords: false,
},
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
externalId: "",
readOnly: false,
manage: false,
hidePasswords: false,
},
},
"user_user-2_collection_data": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("CollectionMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: CollectionMigrator;
const keyDefinitionLike = {
key: "collections",
stateDefinition: {
name: "collection",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 20);
sut = new CollectionMigrator(20, 21);
});
it("should remove collections from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set collections value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"877fef70-be32-439e-8678-b0d80125653d": {
id: "877fef70-be32-439e-8678-b0d80125653d",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
externalId: "",
readOnly: false,
manage: true,
hidePasswords: false,
},
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
externalId: "",
readOnly: false,
manage: false,
hidePasswords: false,
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 21);
sut = new CollectionMigrator(20, 21);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add collection values back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalled();
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
collections: {
encrypted: {
"877fef70-be32-439e-8678-b0d80125653d": {
id: "877fef70-be32-439e-8678-b0d80125653d",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
externalId: "",
readOnly: false,
manage: true,
hidePasswords: false,
},
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
externalId: "",
readOnly: false,
manage: false,
hidePasswords: false,
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,65 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type CollectionDataType = {
id: string;
organizationId: string;
name: string;
externalId: string;
readOnly: boolean;
manage: boolean;
hidePasswords: boolean;
};
type ExpectedAccountType = {
data?: {
collections?: {
encrypted?: Record<string, CollectionDataType>;
};
};
};
const USER_ENCRYPTED_COLLECTIONS: KeyDefinitionLike = {
key: "collections",
stateDefinition: {
name: "collection",
},
};
export class CollectionMigrator extends Migrator<20, 21> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.data?.collections?.encrypted;
if (value != null) {
await helper.setToUser(userId, USER_ENCRYPTED_COLLECTIONS, value);
delete account.data.collections;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, USER_ENCRYPTED_COLLECTIONS);
if (account) {
account.data = Object.assign(account.data ?? {}, {
collections: {
encrypted: value,
},
});
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_ENCRYPTED_COLLECTIONS, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,118 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { CollapsedGroupingsMigrator } from "./22-move-collapsed-groupings-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
collapsedGroupings: ["grouping-1", "grouping-2"],
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_vaultFilter_collapsedGroupings": ["grouping-1", "grouping-2"],
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("CollapsedGroupingsMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: CollapsedGroupingsMigrator;
const keyDefinitionLike = {
key: "collapsedGroupings",
stateDefinition: {
name: "vaultFilter",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 21);
sut = new CollapsedGroupingsMigrator(21, 22);
});
it("should remove collapsedGroupings from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set collapsedGroupings values for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, [
"grouping-1",
"grouping-2",
]);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 22);
sut = new CollapsedGroupingsMigrator(21, 22);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
collapsedGroupings: ["grouping-1", "grouping-2"],
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,49 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
settings?: {
collapsedGroupings?: string[];
};
};
const COLLAPSED_GROUPINGS: KeyDefinitionLike = {
key: "collapsedGroupings",
stateDefinition: {
name: "vaultFilter",
},
};
export class CollapsedGroupingsMigrator extends Migrator<21, 22> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.settings?.collapsedGroupings;
if (value != null) {
await helper.setToUser(userId, COLLAPSED_GROUPINGS, value);
delete account.settings.collapsedGroupings;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, COLLAPSED_GROUPINGS);
if (account) {
account.settings = Object.assign(account.settings ?? {}, {
collapsedGroupings: value,
});
await helper.set(userId, account);
}
await helper.setToUser(userId, COLLAPSED_GROUPINGS, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,131 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
MoveBiometricPromptsToStateProviders,
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
PROMPT_AUTOMATICALLY,
} from "./23-move-biometric-prompts-to-state-providers";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
disableAutoBiometricsPrompt: false,
dismissedBiometricRequirePasswordOnStartCallout: true,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
otherStuff: "otherStuff4",
},
};
}
function rollbackJSON() {
return {
"user_user-1_biometricSettings_dismissedBiometricRequirePasswordOnStartCallout": true,
"user_user-1_biometricSettings_promptAutomatically": "false",
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
otherStuff: "otherStuff4",
},
};
}
describe("MoveBiometricPromptsToStateProviders migrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MoveBiometricPromptsToStateProviders;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 22);
sut = new MoveBiometricPromptsToStateProviders(22, 23);
});
it("should remove biometricUnlock, dismissedBiometricRequirePasswordOnStartCallout, and biometricEncryptionClientKeyHalf from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("user-2", {
otherStuff: "otherStuff4",
});
});
it("should set dismissedBiometricRequirePasswordOnStartCallout value for account that have it", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
true,
);
});
it("should not call extra setToUser", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(2);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 23);
sut = new MoveBiometricPromptsToStateProviders(22, 23);
});
it.each([DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT, PROMPT_AUTOMATICALLY])(
"should null out new values %s",
async (keyDefinition) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinition, null);
},
);
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
disableAutoBiometricsPrompt: false,
dismissedBiometricRequirePasswordOnStartCallout: true,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it.each(["user-2", "user-3"])(
"should not try to restore values to missing accounts",
async (userId) => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith(userId, any());
},
);
});
});

View File

@@ -0,0 +1,99 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
settings?: {
disableAutoBiometricsPrompt?: boolean;
dismissedBiometricRequirePasswordOnStartCallout?: boolean;
};
};
// prompt cancelled is refreshed on every app start/quit/unlock, so we don't need to migrate it
export const DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT: KeyDefinitionLike = {
key: "dismissedBiometricRequirePasswordOnStartCallout",
stateDefinition: { name: "biometricSettings" },
};
export const PROMPT_AUTOMATICALLY: KeyDefinitionLike = {
key: "promptAutomatically",
stateDefinition: { name: "biometricSettings" },
};
export class MoveBiometricPromptsToStateProviders extends Migrator<22, 23> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(
legacyAccounts.map(async ({ userId, account }) => {
if (account == null) {
return;
}
// Move account data
if (account?.settings?.dismissedBiometricRequirePasswordOnStartCallout != null) {
await helper.setToUser(
userId,
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
account.settings.dismissedBiometricRequirePasswordOnStartCallout,
);
}
if (account?.settings?.disableAutoBiometricsPrompt != null) {
await helper.setToUser(
userId,
PROMPT_AUTOMATICALLY,
!account.settings.disableAutoBiometricsPrompt,
);
}
// Delete old account data
delete account?.settings?.dismissedBiometricRequirePasswordOnStartCallout;
delete account?.settings?.disableAutoBiometricsPrompt;
await helper.set(userId, account);
}),
);
}
async rollback(helper: MigrationHelper): Promise<void> {
async function rollbackUser(userId: string, account: ExpectedAccountType) {
let updatedAccount = false;
const userDismissed = await helper.getFromUser<boolean>(
userId,
DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT,
);
if (userDismissed) {
account ??= {};
account.settings ??= {};
updatedAccount = true;
account.settings.dismissedBiometricRequirePasswordOnStartCallout = userDismissed;
await helper.setToUser(userId, DISMISSED_BIOMETRIC_REQUIRE_PASSWORD_ON_START_CALLOUT, null);
}
const userPromptAutomatically = await helper.getFromUser<boolean>(
userId,
PROMPT_AUTOMATICALLY,
);
if (userPromptAutomatically != null) {
account ??= {};
account.settings ??= {};
updatedAccount = true;
account.settings.disableAutoBiometricsPrompt = !userPromptAutomatically;
await helper.setToUser(userId, PROMPT_AUTOMATICALLY, null);
}
if (updatedAccount) {
await helper.set(userId, account);
}
}
const accounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
}
}

View File

@@ -0,0 +1,200 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { SmOnboardingTasksMigrator } from "./24-move-sm-onboarding-key-to-state-providers";
function exampleJSON() {
return {
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
smOnboardingTasks: {
"0bd005de-c722-473b-a00c-b10101006fcd": {
createProject: true,
createSecret: true,
createServiceAccount: true,
importSecrets: true,
},
"2f0d26ec-493a-4ed7-9183-b10d013597c8": {
createProject: false,
createSecret: true,
createServiceAccount: false,
importSecrets: true,
},
},
someOtherProperty: "Some other value",
},
otherStuff: "otherStuff",
},
"user-2": {
settings: {
smOnboardingTasks: {
"000000-0000000-0000000-000000000": {
createProject: false,
createSecret: false,
createServiceAccount: false,
importSecrets: false,
},
},
someOtherProperty: "Some other value",
},
otherStuff: "otherStuff",
},
};
}
function rollbackJSON() {
return {
"user_user-1_smOnboarding_tasks": {
"0bd005de-c722-473b-a00c-b10101006fcd": {
createProject: true,
createSecret: true,
createServiceAccount: true,
importSecrets: true,
},
"2f0d26ec-493a-4ed7-9183-b10d013597c8": {
createProject: false,
createSecret: true,
createServiceAccount: false,
importSecrets: true,
},
},
"user_user-2_smOnboarding_tasks": {
"000000-0000000-0000000-000000000": {
createProject: false,
createSecret: false,
createServiceAccount: false,
importSecrets: false,
},
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
settings: {
someOtherProperty: "Some other value",
},
otherStuff: "otherStuff",
},
"user-2": {
settings: {
someOtherProperty: "Some other value",
},
otherStuff: "otherStuff",
},
};
}
describe("SmOnboardingTasksMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: SmOnboardingTasksMigrator;
const keyDefinitionLike = {
key: "tasks",
stateDefinition: { name: "smOnboarding" },
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 23);
sut = new SmOnboardingTasksMigrator(23, 24);
});
it("should remove smOnboardingTasks from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
someOtherProperty: "Some other value",
},
otherStuff: "otherStuff",
});
});
it("should set smOnboardingTasks provider value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"0bd005de-c722-473b-a00c-b10101006fcd": {
createProject: true,
createSecret: true,
createServiceAccount: true,
importSecrets: true,
},
"2f0d26ec-493a-4ed7-9183-b10d013597c8": {
createProject: false,
createSecret: true,
createServiceAccount: false,
importSecrets: true,
},
});
expect(helper.setToUser).toHaveBeenCalledWith("user-2", keyDefinitionLike, {
"000000-0000000-0000000-000000000": {
createProject: false,
createSecret: false,
createServiceAccount: false,
importSecrets: false,
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 24);
sut = new SmOnboardingTasksMigrator(23, 24);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add smOnboardingTasks back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
smOnboardingTasks: {
"0bd005de-c722-473b-a00c-b10101006fcd": {
createProject: true,
createSecret: true,
createServiceAccount: true,
importSecrets: true,
},
"2f0d26ec-493a-4ed7-9183-b10d013597c8": {
createProject: false,
createSecret: true,
createServiceAccount: false,
importSecrets: true,
},
},
someOtherProperty: "Some other value",
},
otherStuff: "otherStuff",
});
expect(helper.set).toHaveBeenCalledWith("user-2", {
settings: {
smOnboardingTasks: {
"000000-0000000-0000000-000000000": {
createProject: false,
createSecret: false,
createServiceAccount: false,
importSecrets: false,
},
},
someOtherProperty: "Some other value",
},
otherStuff: "otherStuff",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,53 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
settings?: {
smOnboardingTasks?: Record<string, Record<string, boolean>>;
};
};
export const SM_ONBOARDING_TASKS: KeyDefinitionLike = {
key: "tasks",
stateDefinition: { name: "smOnboarding" },
};
export class SmOnboardingTasksMigrator extends Migrator<23, 24> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(
legacyAccounts.map(async ({ userId, account }) => {
// Move account data
if (account?.settings?.smOnboardingTasks != null) {
await helper.setToUser(userId, SM_ONBOARDING_TASKS, account.settings.smOnboardingTasks);
// Delete old account data
delete account.settings.smOnboardingTasks;
await helper.set(userId, account);
}
}),
);
}
async rollback(helper: MigrationHelper): Promise<void> {
async function rollbackUser(userId: string, account: ExpectedAccountType) {
const smOnboardingTasks = await helper.getFromUser<Record<string, Record<string, boolean>>>(
userId,
SM_ONBOARDING_TASKS,
);
if (smOnboardingTasks) {
account ??= {};
account.settings ??= {};
account.settings.smOnboardingTasks = smOnboardingTasks;
await helper.setToUser(userId, SM_ONBOARDING_TASKS, null);
await helper.set(userId, account);
}
}
const accounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
}
}

View File

@@ -0,0 +1,177 @@
import { any, MockProxy } from "jest-mock-extended";
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { ClearClipboardDelayMigrator } from "./25-move-clear-clipboard-to-autofill-settings-state-provider";
export const ClearClipboardDelay = {
Never: null as null,
TenSeconds: 10,
TwentySeconds: 20,
ThirtySeconds: 30,
OneMinute: 60,
TwoMinutes: 120,
FiveMinutes: 300,
} as const;
const AutofillOverlayVisibility = {
Off: 0,
OnButtonClick: 1,
OnFieldFocus: 2,
} as const;
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
clearClipboard: ClearClipboardDelay.TenSeconds,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
clearClipboard: ClearClipboardDelay.Never,
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
"user-3": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
global_autofillSettingsLocal_inlineMenuVisibility: AutofillOverlayVisibility.OnButtonClick,
"user_user-1_autofillSettingsLocal_clearClipboardDelay": ClearClipboardDelay.TenSeconds,
"user_user-2_autofillSettingsLocal_clearClipboardDelay": ClearClipboardDelay.Never,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
const autofillSettingsLocalStateDefinition: {
stateDefinition: StateDefinitionLike;
} = {
stateDefinition: {
name: "autofillSettingsLocal",
},
};
describe("ClearClipboardDelayMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: ClearClipboardDelayMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 24);
sut = new ClearClipboardDelayMigrator(24, 25);
});
it("should remove clearClipboard setting from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("user-2", {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should set autofill setting values for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(2);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
ClearClipboardDelay.TenSeconds,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-2",
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
ClearClipboardDelay.Never,
);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 25);
sut = new ClearClipboardDelayMigrator(24, 25);
});
it("should null out new values for each account", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(2);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-2",
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
null,
);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
clearClipboard: ClearClipboardDelay.TenSeconds,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("user-2", {
settings: {
clearClipboard: ClearClipboardDelay.Never,
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,92 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ClearClipboardDelay = {
Never: null as null,
TenSeconds: 10,
TwentySeconds: 20,
ThirtySeconds: 30,
OneMinute: 60,
TwoMinutes: 120,
FiveMinutes: 300,
} as const;
type ClearClipboardDelaySetting = (typeof ClearClipboardDelay)[keyof typeof ClearClipboardDelay];
type ExpectedAccountState = {
settings?: {
clearClipboard?: ClearClipboardDelaySetting;
};
};
const autofillSettingsLocalStateDefinition: {
stateDefinition: StateDefinitionLike;
} = {
stateDefinition: {
name: "autofillSettingsLocal",
},
};
export class ClearClipboardDelayMigrator extends Migrator<24, 25> {
async migrate(helper: MigrationHelper): Promise<void> {
// account state (e.g. account settings -> state provider framework keys)
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
// migrate account state
async function migrateAccount(userId: string, account: ExpectedAccountState): Promise<void> {
const accountSettings = account?.settings;
if (accountSettings?.clearClipboard !== undefined) {
await helper.setToUser(
userId,
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
accountSettings.clearClipboard,
);
delete account.settings.clearClipboard;
// update the state account settings with the migrated values deleted
await helper.set(userId, account);
}
}
}
async rollback(helper: MigrationHelper): Promise<void> {
// account state (e.g. state provider framework keys -> account settings)
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
// rollback account state
async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise<void> {
let settings = account?.settings || {};
const clearClipboardDelay: ClearClipboardDelaySetting = await helper.getFromUser(userId, {
...autofillSettingsLocalStateDefinition,
key: "clearClipboardDelay",
});
// update new settings and remove the account state provider framework keys for the rolled back values
if (clearClipboardDelay !== undefined) {
settings = { ...settings, clearClipboard: clearClipboardDelay };
await helper.setToUser(
userId,
{ ...autofillSettingsLocalStateDefinition, key: "clearClipboardDelay" },
null,
);
// commit updated settings to state
await helper.set(userId, {
...account,
settings,
});
}
}
}
}

View File

@@ -0,0 +1,112 @@
import { any, MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { RevertLastSyncMigrator } from "./26-revert-move-last-sync-to-state-provider";
function rollbackJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
profile: {
lastSync: "2024-01-24T00:00:00.000Z",
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function exampleJSON() {
return {
"user_user-1_sync_lastSync": "2024-01-24T00:00:00.000Z",
"user_user-2_sync_lastSync": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
profile: {
lastSync: "2024-01-24T00:00:00.000Z",
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("LastSyncMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: RevertLastSyncMigrator;
const keyDefinitionLike = {
key: "lastSync",
stateDefinition: {
name: "sync",
},
};
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 26);
sut = new RevertLastSyncMigrator(25, 26);
});
it("should remove lastSync from all accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should set lastSync provider value for each account", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
keyDefinitionLike,
"2024-01-24T00:00:00.000Z",
);
expect(helper.setToUser).toHaveBeenCalledWith("user-2", keyDefinitionLike, null);
});
});
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 25);
sut = new RevertLastSyncMigrator(25, 26);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add lastSync back to accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
profile: {
lastSync: "2024-01-24T00:00:00.000Z",
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-2", any());
});
});
});

View File

@@ -0,0 +1,49 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
profile?: {
lastSync?: string;
};
};
const LAST_SYNC_KEY: KeyDefinitionLike = {
key: "lastSync",
stateDefinition: {
name: "sync",
},
};
export class RevertLastSyncMigrator extends Migrator<25, 26> {
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.profile?.lastSync;
await helper.setToUser(userId, LAST_SYNC_KEY, value ?? null);
if (value != null) {
delete account.profile.lastSync;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, LAST_SYNC_KEY);
if (account) {
account.profile = Object.assign(account.profile ?? {}, {
lastSync: value,
});
await helper.set(userId, account);
}
await helper.setToUser(userId, LAST_SYNC_KEY, null);
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,166 @@
import { any, MockProxy } from "jest-mock-extended";
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { BadgeSettingsMigrator } from "./27-move-badge-settings-to-state-providers";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
disableBadgeCounter: true,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
disableBadgeCounter: false,
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
"user-3": {
settings: {
otherStuff: "otherStuff6",
},
otherStuff: "otherStuff7",
},
};
}
function rollbackJSON() {
return {
"user_user-1_badgeSettings_enableBadgeCounter": false,
"user_user-2_badgeSettings_enableBadgeCounter": true,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
"user-3": {
settings: {
otherStuff: "otherStuff6",
},
otherStuff: "otherStuff7",
},
};
}
const badgeSettingsStateDefinition: {
stateDefinition: StateDefinitionLike;
} = {
stateDefinition: {
name: "badgeSettings",
},
};
describe("BadgeSettingsMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: BadgeSettingsMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 26);
sut = new BadgeSettingsMigrator(26, 27);
});
it("should remove disableBadgeCounter setting from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("user-2", {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should set badge setting values for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(2);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...badgeSettingsStateDefinition, key: "enableBadgeCounter" },
false,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-2",
{ ...badgeSettingsStateDefinition, key: "enableBadgeCounter" },
true,
);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 27);
sut = new BadgeSettingsMigrator(26, 27);
});
it("should null out new values for each account", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(2);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...badgeSettingsStateDefinition, key: "enableBadgeCounter" },
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-2",
{ ...badgeSettingsStateDefinition, key: "enableBadgeCounter" },
null,
);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
disableBadgeCounter: true,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("user-2", {
settings: {
disableBadgeCounter: false,
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,73 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountState = {
settings?: {
disableBadgeCounter?: boolean;
};
};
const enableBadgeCounterKeyDefinition: KeyDefinitionLike = {
stateDefinition: {
name: "badgeSettings",
},
key: "enableBadgeCounter",
};
export class BadgeSettingsMigrator extends Migrator<26, 27> {
async migrate(helper: MigrationHelper): Promise<void> {
// account state (e.g. account settings -> state provider framework keys)
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
// migrate account state
async function migrateAccount(userId: string, account: ExpectedAccountState): Promise<void> {
const accountSettings = account?.settings;
if (accountSettings?.disableBadgeCounter != undefined) {
await helper.setToUser(
userId,
enableBadgeCounterKeyDefinition,
!accountSettings.disableBadgeCounter,
);
delete account.settings.disableBadgeCounter;
// update the state account settings with the migrated values deleted
await helper.set(userId, account);
}
}
}
async rollback(helper: MigrationHelper): Promise<void> {
// account state (e.g. state provider framework keys -> account settings)
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
// rollback account state
async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise<void> {
let settings = account?.settings || {};
const enableBadgeCounter: boolean = await helper.getFromUser(
userId,
enableBadgeCounterKeyDefinition,
);
// update new settings and remove the account state provider framework keys for the rolled back values
if (enableBadgeCounter != undefined) {
settings = { ...settings, disableBadgeCounter: !enableBadgeCounter };
await helper.setToUser(userId, enableBadgeCounterKeyDefinition, null);
// commit updated settings to state
await helper.set(userId, {
...account,
settings,
});
}
}
}
}

View File

@@ -0,0 +1,120 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
BIOMETRIC_UNLOCK_ENABLED,
MoveBiometricUnlockToStateProviders,
} from "./28-move-biometric-unlock-to-state-providers";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
biometricUnlock: true,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
otherStuff: "otherStuff4",
},
};
}
function rollbackJSON() {
return {
"user_user-1_biometricSettings_biometricUnlockEnabled": true,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
otherStuff: "otherStuff4",
},
};
}
describe("MoveBiometricPromptsToStateProviders migrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MoveBiometricUnlockToStateProviders;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 27);
sut = new MoveBiometricUnlockToStateProviders(27, 28);
});
it("removes biometricUnlock from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("user-2", {
otherStuff: "otherStuff4",
});
});
it("sets biometricUnlock value for account that have it", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", BIOMETRIC_UNLOCK_ENABLED, true);
});
it("should not call extra setToUser", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(1);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 28);
sut = new MoveBiometricUnlockToStateProviders(27, 28);
});
it("nulls out new values", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", BIOMETRIC_UNLOCK_ENABLED, null);
});
it("adds explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
biometricUnlock: true,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it.each(["user-2", "user-3"])(
"does not restore values when accounts are not present",
async (userId) => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith(userId, any());
},
);
});
});

View File

@@ -0,0 +1,58 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
settings?: {
biometricUnlock?: boolean;
};
};
export const BIOMETRIC_UNLOCK_ENABLED: KeyDefinitionLike = {
key: "biometricUnlockEnabled",
stateDefinition: { name: "biometricSettings" },
};
export class MoveBiometricUnlockToStateProviders extends Migrator<27, 28> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyAccounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(
legacyAccounts.map(async ({ userId, account }) => {
if (account == null) {
return;
}
// Move account data
if (account?.settings?.biometricUnlock != null) {
await helper.setToUser(
userId,
BIOMETRIC_UNLOCK_ENABLED,
account.settings.biometricUnlock,
);
}
// Delete old account data
delete account?.settings?.biometricUnlock;
await helper.set(userId, account);
}),
);
}
async rollback(helper: MigrationHelper): Promise<void> {
async function rollbackUser(userId: string, account: ExpectedAccountType) {
const biometricUnlock = await helper.getFromUser<boolean>(userId, BIOMETRIC_UNLOCK_ENABLED);
if (biometricUnlock != null) {
account ??= {};
account.settings ??= {};
account.settings.biometricUnlock = biometricUnlock;
await helper.setToUser(userId, BIOMETRIC_UNLOCK_ENABLED, null);
await helper.set(userId, account);
}
}
const accounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
}
}

View File

@@ -0,0 +1,145 @@
import { any, MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { ProviderMigrator } from "./28-move-provider-state-to-state-provider";
function exampleProvider1() {
return JSON.stringify({
id: "id",
name: "name",
status: 0,
type: 0,
enabled: true,
useEvents: true,
});
}
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
providers: {
"provider-id-1": exampleProvider1(),
"provider-id-2": {
// ...
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_providers_providers": {
"provider-id-1": exampleProvider1(),
"provider-id-2": {
// ...
},
},
"user_user-2_providers_providers": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("ProviderMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: ProviderMigrator;
const keyDefinitionLike = {
key: "providers",
stateDefinition: {
name: "providers",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 28);
sut = new ProviderMigrator(27, 28);
});
it("should remove providers from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set providers value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"provider-id-1": exampleProvider1(),
"provider-id-2": {
// ...
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 27);
sut = new ProviderMigrator(27, 28);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
providers: {
"provider-id-1": exampleProvider1(),
"provider-id-2": {
// ...
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,77 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
enum ProviderUserStatusType {
Invited = 0,
Accepted = 1,
Confirmed = 2,
Revoked = -1,
}
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
enum ProviderUserType {
ProviderAdmin = 0,
ServiceUser = 1,
}
type ProviderData = {
id: string;
name: string;
status: ProviderUserStatusType;
type: ProviderUserType;
enabled: boolean;
userId: string;
useEvents: boolean;
};
type ExpectedAccountType = {
data?: {
providers?: Record<string, Jsonify<ProviderData>>;
};
};
const USER_PROVIDERS: KeyDefinitionLike = {
key: "providers",
stateDefinition: {
name: "providers",
},
};
export class ProviderMigrator extends Migrator<27, 28> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.data?.providers;
if (value != null) {
await helper.setToUser(userId, USER_PROVIDERS, value);
delete account.data.providers;
await helper.set(userId, account);
}
}
await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account)));
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, USER_PROVIDERS);
if (account) {
account.data = Object.assign(account.data ?? {}, {
providers: value,
});
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_PROVIDERS, null);
}
await Promise.all(accounts.map(({ userId, account }) => rollbackAccount(userId, account)));
}
}

View File

@@ -0,0 +1,102 @@
import { MockProxy } from "jest-mock-extended";
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { UserNotificationSettingsKeyMigrator } from "./29-move-user-notification-settings-to-state-provider";
function exampleJSON() {
return {
global: {
disableAddLoginNotification: false,
disableChangedPasswordNotification: false,
otherStuff: "otherStuff1",
},
};
}
function rollbackJSON() {
return {
global_userNotificationSettings_enableAddedLoginPrompt: true,
global_userNotificationSettings_enableChangedPasswordPrompt: true,
global: {
otherStuff: "otherStuff1",
},
};
}
const userNotificationSettingsLocalStateDefinition: {
stateDefinition: StateDefinitionLike;
} = {
stateDefinition: {
name: "userNotificationSettings",
},
};
describe("ProviderKeysMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: UserNotificationSettingsKeyMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 28);
sut = new UserNotificationSettingsKeyMigrator(28, 29);
});
it("should remove disableAddLoginNotification and disableChangedPasswordNotification global setting", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("global", { otherStuff: "otherStuff1" });
expect(helper.set).toHaveBeenCalledWith("global", { otherStuff: "otherStuff1" });
});
it("should set global user notification setting values", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(2);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{ ...userNotificationSettingsLocalStateDefinition, key: "enableAddedLoginPrompt" },
true,
);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{ ...userNotificationSettingsLocalStateDefinition, key: "enableChangedPasswordPrompt" },
true,
);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 29);
sut = new UserNotificationSettingsKeyMigrator(28, 29);
});
it("should null out new global values", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(2);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{ ...userNotificationSettingsLocalStateDefinition, key: "enableAddedLoginPrompt" },
null,
);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{ ...userNotificationSettingsLocalStateDefinition, key: "enableChangedPasswordPrompt" },
null,
);
});
it("should add explicit global values back", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("global", {
disableAddLoginNotification: false,
otherStuff: "otherStuff1",
});
expect(helper.set).toHaveBeenCalledWith("global", {
disableChangedPasswordNotification: false,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,105 @@
import { MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedGlobalState = {
disableAddLoginNotification?: boolean;
disableChangedPasswordNotification?: boolean;
};
export class UserNotificationSettingsKeyMigrator extends Migrator<28, 29> {
async migrate(helper: MigrationHelper): Promise<void> {
const globalState = await helper.get<ExpectedGlobalState>("global");
// disableAddLoginNotification -> enableAddedLoginPrompt
if (globalState?.disableAddLoginNotification != null) {
await helper.setToGlobal(
{
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableAddedLoginPrompt",
},
!globalState.disableAddLoginNotification,
);
// delete `disableAddLoginNotification` from state global
delete globalState.disableAddLoginNotification;
await helper.set<ExpectedGlobalState>("global", globalState);
}
// disableChangedPasswordNotification -> enableChangedPasswordPrompt
if (globalState?.disableChangedPasswordNotification != null) {
await helper.setToGlobal(
{
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableChangedPasswordPrompt",
},
!globalState.disableChangedPasswordNotification,
);
// delete `disableChangedPasswordNotification` from state global
delete globalState.disableChangedPasswordNotification;
await helper.set<ExpectedGlobalState>("global", globalState);
}
}
async rollback(helper: MigrationHelper): Promise<void> {
const globalState = (await helper.get<ExpectedGlobalState>("global")) || {};
const enableAddedLoginPrompt: boolean = await helper.getFromGlobal({
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableAddedLoginPrompt",
});
const enableChangedPasswordPrompt: boolean = await helper.getFromGlobal({
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableChangedPasswordPrompt",
});
// enableAddedLoginPrompt -> disableAddLoginNotification
if (enableAddedLoginPrompt) {
await helper.set<ExpectedGlobalState>("global", {
...globalState,
disableAddLoginNotification: !enableAddedLoginPrompt,
});
// remove the global state provider framework key for `enableAddedLoginPrompt`
await helper.setToGlobal(
{
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableAddedLoginPrompt",
},
null,
);
}
// enableChangedPasswordPrompt -> disableChangedPasswordNotification
if (enableChangedPasswordPrompt) {
await helper.set<ExpectedGlobalState>("global", {
...globalState,
disableChangedPasswordNotification: !enableChangedPasswordPrompt,
});
// remove the global state provider framework key for `enableChangedPasswordPrompt`
await helper.setToGlobal(
{
stateDefinition: {
name: "userNotificationSettings",
},
key: "enableChangedPasswordPrompt",
},
null,
);
}
}
}

View File

@@ -0,0 +1,192 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { PolicyMigrator } from "./30-move-policy-state-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
policies: {
encrypted: {
"policy-1": {
id: "policy-1",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
type: 9, // max vault timeout
enabled: true,
data: {
hours: 1,
minutes: 30,
action: "lock",
},
},
"policy-2": {
id: "policy-2",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
type: 3, // single org
enabled: true,
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_policies_policies": {
"policy-1": {
id: "policy-1",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
type: 9,
enabled: true,
data: {
hours: 1,
minutes: 30,
action: "lock",
},
},
"policy-2": {
id: "policy-2",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
type: 3,
enabled: true,
},
},
"user_user-2_policies_policies": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("PoliciesMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: PolicyMigrator;
const keyDefinitionLike = {
key: "policies",
stateDefinition: {
name: "policies",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 22);
sut = new PolicyMigrator(29, 30);
});
it("should remove policies from all old accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set policies value in StateProvider framework for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"policy-1": {
id: "policy-1",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
type: 9,
enabled: true,
data: {
hours: 1,
minutes: 30,
action: "lock",
},
},
"policy-2": {
id: "policy-2",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
type: 3,
enabled: true,
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 23);
sut = new PolicyMigrator(29, 30);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add policy values back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalled();
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
policies: {
encrypted: {
"policy-1": {
id: "policy-1",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
type: 9,
enabled: true,
data: {
hours: 1,
minutes: 30,
action: "lock",
},
},
"policy-2": {
id: "policy-2",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
type: 3,
enabled: true,
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,80 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
enum PolicyType {
TwoFactorAuthentication = 0, // Requires users to have 2fa enabled
MasterPassword = 1, // Sets minimum requirements for master password complexity
PasswordGenerator = 2, // Sets minimum requirements/default type for generated passwords/passphrases
SingleOrg = 3, // Allows users to only be apart of one organization
RequireSso = 4, // Requires users to authenticate with SSO
PersonalOwnership = 5, // Disables personal vault ownership for adding/cloning items
DisableSend = 6, // Disables the ability to create and edit Bitwarden Sends
SendOptions = 7, // Sets restrictions or defaults for Bitwarden Sends
ResetPassword = 8, // Allows orgs to use reset password : also can enable auto-enrollment during invite flow
MaximumVaultTimeout = 9, // Sets the maximum allowed vault timeout
DisablePersonalVaultExport = 10, // Disable personal vault export
ActivateAutofill = 11, // Activates autofill with page load on the browser extension
}
type PolicyDataType = {
id: string;
organizationId: string;
type: PolicyType;
data: Record<string, string | number | boolean>;
enabled: boolean;
};
type ExpectedAccountType = {
data?: {
policies?: {
encrypted?: Record<string, PolicyDataType>;
};
};
};
const POLICIES_KEY: KeyDefinitionLike = {
key: "policies",
stateDefinition: {
name: "policies",
},
};
export class PolicyMigrator extends Migrator<29, 30> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.data?.policies?.encrypted;
if (value != null) {
await helper.setToUser(userId, POLICIES_KEY, value);
delete account.data.policies;
await helper.set(userId, account);
}
}
await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account)));
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, POLICIES_KEY);
if (account) {
account.data = Object.assign(account.data ?? {}, {
policies: {
encrypted: value,
},
});
await helper.set(userId, account);
}
await helper.setToUser(userId, POLICIES_KEY, null);
}
await Promise.all(accounts.map(({ userId, account }) => rollbackAccount(userId, account)));
}
}

View File

@@ -0,0 +1,91 @@
import { any, MockProxy } from "jest-mock-extended";
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { EnableContextMenuMigrator } from "./31-move-enable-context-menu-to-autofill-settings-state-provider";
function exampleJSON() {
return {
global: {
disableContextMenuItem: true,
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
};
}
function rollbackJSON() {
return {
global_autofillSettings_enableContextMenu: false,
global: {
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
};
}
const enableContextMenuKeyDefinition: KeyDefinitionLike = {
stateDefinition: {
name: "autofillSettings",
},
key: "enableContextMenu",
};
describe("EnableContextMenuMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: EnableContextMenuMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 30);
sut = new EnableContextMenuMigrator(30, 31);
});
it("should remove global disableContextMenuItem setting", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
});
});
it("should set enableContextMenu globally", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(enableContextMenuKeyDefinition, false);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 31);
sut = new EnableContextMenuMigrator(30, 31);
});
it("should null out new enableContextMenu global value", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(enableContextMenuKeyDefinition, null);
});
it("should add disableContextMenuItem global value back", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
disableContextMenuItem: true,
otherStuff: "otherStuff1",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,46 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedGlobalState = {
disableContextMenuItem?: boolean;
};
const enableContextMenuKeyDefinition: KeyDefinitionLike = {
stateDefinition: {
name: "autofillSettings",
},
key: "enableContextMenu",
};
export class EnableContextMenuMigrator extends Migrator<30, 31> {
async migrate(helper: MigrationHelper): Promise<void> {
const globalState = await helper.get<ExpectedGlobalState>("global");
// disableContextMenuItem -> enableContextMenu
if (globalState?.disableContextMenuItem != null) {
await helper.setToGlobal(enableContextMenuKeyDefinition, !globalState.disableContextMenuItem);
// delete `disableContextMenuItem` from state global
delete globalState.disableContextMenuItem;
await helper.set<ExpectedGlobalState>("global", globalState);
}
}
async rollback(helper: MigrationHelper): Promise<void> {
const globalState = (await helper.get<ExpectedGlobalState>("global")) || {};
const enableContextMenu: boolean = await helper.getFromGlobal(enableContextMenuKeyDefinition);
// enableContextMenu -> disableContextMenuItem
if (enableContextMenu != null) {
await helper.set<ExpectedGlobalState>("global", {
...globalState,
disableContextMenuItem: !enableContextMenu,
});
// remove the global state provider framework key for `enableContextMenu`
await helper.setToGlobal(enableContextMenuKeyDefinition, null);
}
}
}

View File

@@ -0,0 +1,77 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { LOCALE_KEY, PreferredLanguageMigrator } from "./32-move-preferred-language";
function exampleJSON() {
return {
global: {
locale: "en",
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
};
}
function rollbackJSON() {
return {
global_translation_locale: "en",
global: {
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
};
}
describe("PreferredLanguageMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: PreferredLanguageMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 31);
sut = new PreferredLanguageMigrator(31, 32);
});
it("should remove locale setting from global", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
});
});
it("should set locale for global state provider", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(LOCALE_KEY, "en");
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 32);
sut = new PreferredLanguageMigrator(31, 32);
});
it("should null out new values for global", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(LOCALE_KEY, null);
});
it("should add locale back to the old global object", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
locale: "en",
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,39 @@
import { MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedGlobal = {
locale?: string;
};
export const LOCALE_KEY = {
key: "locale",
stateDefinition: {
name: "translation",
},
};
export class PreferredLanguageMigrator extends Migrator<31, 32> {
async migrate(helper: MigrationHelper): Promise<void> {
// global state
const global = await helper.get<ExpectedGlobal>("global");
if (!global?.locale) {
return;
}
await helper.setToGlobal(LOCALE_KEY, global.locale);
delete global.locale;
await helper.set("global", global);
}
async rollback(helper: MigrationHelper): Promise<void> {
const locale = await helper.getFromGlobal<string>(LOCALE_KEY);
if (!locale) {
return;
}
const global = (await helper.get<ExpectedGlobal>("global")) ?? {};
global.locale = locale;
await helper.set("global", global);
await helper.setToGlobal(LOCALE_KEY, null);
}
}

View File

@@ -0,0 +1,213 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
ANONYMOUS_APP_ID_KEY,
APP_ID_KEY,
AppIdMigrator,
} from "./33-move-app-id-to-state-providers";
function exampleJSON() {
return {
appId: "appId",
anonymousAppId: "anonymousAppId",
otherStuff: "otherStuff1",
};
}
function missingAppIdJSON() {
return {
anonymousAppId: "anonymousAppId",
otherStuff: "otherStuff1",
};
}
function missingAnonymousAppIdJSON() {
return {
appId: "appId",
otherStuff: "otherStuff1",
};
}
function missingBothJSON() {
return {
otherStuff: "otherStuff1",
};
}
function rollbackJSON() {
return {
global_applicationId_appId: "appId",
global_applicationId_anonymousAppId: "anonymousAppId",
otherStuff: "otherStuff1",
};
}
describe("AppIdMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: AppIdMigrator;
describe("migrate with both ids", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 32);
sut = new AppIdMigrator(32, 33);
});
it("removes appId", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("appId", null);
});
it("removes anonymousAppId", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("anonymousAppId", null);
});
it("sets appId", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(APP_ID_KEY, "appId");
});
it("sets anonymousAppId", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, "anonymousAppId");
});
});
describe("migrate with missing appId", () => {
beforeEach(() => {
helper = mockMigrationHelper(missingAppIdJSON(), 32);
sut = new AppIdMigrator(32, 33);
});
it("does not set appId", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any());
});
it("removes anonymousAppId", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("anonymousAppId", null);
});
it("does not set appId", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any());
});
it("sets anonymousAppId", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, "anonymousAppId");
});
});
describe("migrate with missing anonymousAppId", () => {
beforeEach(() => {
helper = mockMigrationHelper(missingAnonymousAppIdJSON(), 32);
sut = new AppIdMigrator(32, 33);
});
it("sets appId", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(APP_ID_KEY, "appId");
});
it("does not set anonymousAppId", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).not.toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, any());
});
it("removes appId", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("appId", null);
});
it("does not remove anonymousAppId", async () => {
await sut.migrate(helper);
expect(helper.set).not.toHaveBeenCalledWith("anonymousAppId", any());
});
});
describe("migrate with missing appId and anonymousAppId", () => {
beforeEach(() => {
helper = mockMigrationHelper(missingBothJSON(), 32);
sut = new AppIdMigrator(32, 33);
});
it("does not set appId", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any());
});
it("does not set anonymousAppId", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).not.toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, any());
});
it("does not remove appId", async () => {
await sut.migrate(helper);
expect(helper.set).not.toHaveBeenCalledWith("appId", any());
});
it("does not remove anonymousAppId", async () => {
await sut.migrate(helper);
expect(helper.set).not.toHaveBeenCalledWith("anonymousAppId", any());
});
});
describe("rollback with both Ids", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 33);
sut = new AppIdMigrator(32, 33);
});
it("removes appId", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(APP_ID_KEY, null);
});
it("sets appId", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("appId", "appId");
});
it("removes anonymousAppId", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, null);
});
it("sets anonymousAppId", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("anonymousAppId", "anonymousAppId");
});
});
describe("rollback missing both Ids", () => {
beforeEach(() => {
helper = mockMigrationHelper(missingBothJSON(), 33);
sut = new AppIdMigrator(32, 33);
});
it("does not set appId for providers", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any());
});
it("does not set anonymousAppId for providers", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).not.toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, any());
});
it("does not revert appId", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("appId", any());
});
it("does not revert anonymousAppId", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("anonymousAppId", any());
});
});
});

View File

@@ -0,0 +1,46 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
export const APP_ID_STORAGE_KEY = "appId";
export const ANONYMOUS_APP_ID_STORAGE_KEY = "anonymousAppId";
export const APP_ID_KEY: KeyDefinitionLike = {
key: APP_ID_STORAGE_KEY,
stateDefinition: { name: "applicationId" },
};
export const ANONYMOUS_APP_ID_KEY: KeyDefinitionLike = {
key: ANONYMOUS_APP_ID_STORAGE_KEY,
stateDefinition: { name: "applicationId" },
};
export class AppIdMigrator extends Migrator<32, 33> {
async migrate(helper: MigrationHelper): Promise<void> {
const appId = await helper.get<string>(APP_ID_STORAGE_KEY);
const anonymousAppId = await helper.get<string>(ANONYMOUS_APP_ID_STORAGE_KEY);
if (appId != null) {
await helper.setToGlobal(APP_ID_KEY, appId);
await helper.set(APP_ID_STORAGE_KEY, null);
}
if (anonymousAppId != null) {
await helper.setToGlobal(ANONYMOUS_APP_ID_KEY, anonymousAppId);
await helper.set(ANONYMOUS_APP_ID_STORAGE_KEY, null);
}
}
async rollback(helper: MigrationHelper): Promise<void> {
const appId = await helper.getFromGlobal<string>(APP_ID_KEY);
const anonymousAppId = await helper.getFromGlobal<string>(ANONYMOUS_APP_ID_KEY);
if (appId != null) {
await helper.set(APP_ID_STORAGE_KEY, appId);
await helper.setToGlobal(APP_ID_KEY, null);
}
if (anonymousAppId != null) {
await helper.set(ANONYMOUS_APP_ID_STORAGE_KEY, anonymousAppId);
await helper.setToGlobal(ANONYMOUS_APP_ID_KEY, null);
}
}
}

View File

@@ -0,0 +1,255 @@
import { any, MockProxy } from "jest-mock-extended";
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { DomainSettingsMigrator } from "./34-move-domain-settings-to-state-providers";
const mockNeverDomains = { "bitwarden.test": null, locahost: null, "www.example.com": null } as {
[key: string]: null;
};
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
neverDomains: mockNeverDomains,
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
defaultUriMatch: 3,
settings: {
equivalentDomains: [] as string[][],
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
settings: {
equivalentDomains: [["apple.com", "icloud.com"]],
},
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
"user-3": {
settings: {
defaultUriMatch: 1,
otherStuff: "otherStuff6",
},
otherStuff: "otherStuff7",
},
"user-4": {
settings: {
otherStuff: "otherStuff8",
},
otherStuff: "otherStuff9",
},
};
}
function rollbackJSON() {
return {
global_domainSettings_neverDomains: mockNeverDomains,
"user_user-1_domainSettings_defaultUriMatchStrategy": 3,
"user_user-1_domainSettings_equivalentDomains": [] as string[][],
"user_user-2_domainSettings_equivalentDomains": [["apple.com", "icloud.com"]],
"user_user-3_domainSettings_defaultUriMatchStrategy": 1,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
"user-3": {
settings: {
otherStuff: "otherStuff6",
},
otherStuff: "otherStuff7",
},
"user-4": {
settings: {
otherStuff: "otherStuff8",
},
otherStuff: "otherStuff9",
},
};
}
const domainSettingsStateDefinition: {
stateDefinition: StateDefinitionLike;
} = {
stateDefinition: {
name: "domainSettings",
},
};
describe("DomainSettingsMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: DomainSettingsMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 33);
sut = new DomainSettingsMigrator(33, 34);
});
it("should remove global neverDomains and defaultUriMatch and equivalentDomains settings from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(4);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
});
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("user-2", {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
expect(helper.set).toHaveBeenCalledWith("user-3", {
settings: {
otherStuff: "otherStuff6",
},
otherStuff: "otherStuff7",
});
});
it("should set global neverDomains and defaultUriMatchStrategy and equivalentDomains setting values for each account", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{ ...domainSettingsStateDefinition, key: "neverDomains" },
mockNeverDomains,
);
expect(helper.setToUser).toHaveBeenCalledTimes(4);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" },
3,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...domainSettingsStateDefinition, key: "equivalentDomains" },
[],
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-2",
{ ...domainSettingsStateDefinition, key: "equivalentDomains" },
[["apple.com", "icloud.com"]],
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-3",
{ ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" },
1,
);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 34);
sut = new DomainSettingsMigrator(33, 34);
});
it("should null out new values globally and for each account", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(
{ ...domainSettingsStateDefinition, key: "neverDomains" },
null,
);
expect(helper.setToUser).toHaveBeenCalledTimes(4);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" },
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...domainSettingsStateDefinition, key: "equivalentDomains" },
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-2",
{ ...domainSettingsStateDefinition, key: "equivalentDomains" },
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-3",
{ ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" },
null,
);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(4);
expect(helper.set).toHaveBeenCalledWith("global", {
neverDomains: mockNeverDomains,
otherStuff: "otherStuff1",
});
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
defaultUriMatch: 3,
settings: {
equivalentDomains: [] as string[][],
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("user-2", {
settings: {
settings: {
equivalentDomains: [["apple.com", "icloud.com"]],
},
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
expect(helper.set).toHaveBeenCalledWith("user-3", {
settings: {
defaultUriMatch: 1,
otherStuff: "otherStuff6",
},
otherStuff: "otherStuff7",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-4", any());
});
});
});

View File

@@ -0,0 +1,171 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const UriMatchStrategy = {
Domain: 0,
Host: 1,
StartsWith: 2,
Exact: 3,
RegularExpression: 4,
Never: 5,
} as const;
type UriMatchStrategySetting = (typeof UriMatchStrategy)[keyof typeof UriMatchStrategy];
type ExpectedAccountState = {
settings?: {
defaultUriMatch?: UriMatchStrategySetting;
settings?: {
equivalentDomains?: string[][];
};
};
};
type ExpectedGlobalState = {
neverDomains?: { [key: string]: null };
};
const defaultUriMatchStrategyDefinition: KeyDefinitionLike = {
stateDefinition: {
name: "domainSettings",
},
key: "defaultUriMatchStrategy",
};
const equivalentDomainsDefinition: KeyDefinitionLike = {
stateDefinition: {
name: "domainSettings",
},
key: "equivalentDomains",
};
const neverDomainsDefinition: KeyDefinitionLike = {
stateDefinition: {
name: "domainSettings",
},
key: "neverDomains",
};
export class DomainSettingsMigrator extends Migrator<33, 34> {
async migrate(helper: MigrationHelper): Promise<void> {
let updateAccount = false;
// global state ("neverDomains")
const globalState = await helper.get<ExpectedGlobalState>("global");
if (globalState?.neverDomains != null) {
await helper.setToGlobal(neverDomainsDefinition, globalState.neverDomains);
// delete `neverDomains` from state global
delete globalState.neverDomains;
await helper.set<ExpectedGlobalState>("global", globalState);
}
// account state ("defaultUriMatch" and "settings.equivalentDomains")
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
// migrate account state
async function migrateAccount(userId: string, account: ExpectedAccountState): Promise<void> {
const accountSettings = account?.settings;
if (accountSettings?.defaultUriMatch != undefined) {
await helper.setToUser(
userId,
defaultUriMatchStrategyDefinition,
accountSettings.defaultUriMatch,
);
delete account.settings.defaultUriMatch;
updateAccount = true;
}
if (accountSettings?.settings?.equivalentDomains != undefined) {
await helper.setToUser(
userId,
equivalentDomainsDefinition,
accountSettings.settings.equivalentDomains,
);
delete account.settings.settings.equivalentDomains;
delete account.settings.settings;
updateAccount = true;
}
if (updateAccount) {
// update the state account settings with the migrated values deleted
await helper.set(userId, account);
}
}
}
async rollback(helper: MigrationHelper): Promise<void> {
let updateAccount = false;
// global state ("neverDomains")
const globalState = (await helper.get<ExpectedGlobalState>("global")) || {};
const neverDomains: { [key: string]: null } =
await helper.getFromGlobal(neverDomainsDefinition);
if (neverDomains != null) {
await helper.set<ExpectedGlobalState>("global", {
...globalState,
neverDomains: neverDomains,
});
// remove the global state provider framework key for `neverDomains`
await helper.setToGlobal(neverDomainsDefinition, null);
}
// account state ("defaultUriMatchStrategy" and "equivalentDomains")
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
// rollback account state
async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise<void> {
let settings = account?.settings || {};
const defaultUriMatchStrategy: UriMatchStrategySetting = await helper.getFromUser(
userId,
defaultUriMatchStrategyDefinition,
);
const equivalentDomains: string[][] = await helper.getFromUser(
userId,
equivalentDomainsDefinition,
);
// update new settings and remove the account state provider framework keys for the rolled back values
if (defaultUriMatchStrategy != null) {
settings = { ...settings, defaultUriMatch: defaultUriMatchStrategy };
await helper.setToUser(userId, defaultUriMatchStrategyDefinition, null);
updateAccount = true;
}
if (equivalentDomains != null) {
settings = { ...settings, settings: { equivalentDomains } };
await helper.setToUser(userId, equivalentDomainsDefinition, null);
updateAccount = true;
}
// commit updated settings to state
if (updateAccount) {
await helper.set(userId, {
...account,
settings,
});
}
}
}
}

View File

@@ -0,0 +1,78 @@
import { runMigrator } from "../migration-helper.spec";
import { MoveThemeToStateProviderMigrator } from "./35-move-theme-to-state-providers";
describe("MoveThemeToStateProviders", () => {
const sut = new MoveThemeToStateProviderMigrator(34, 35);
describe("migrate", () => {
it("migrates global theme and deletes it", async () => {
const output = await runMigrator(sut, {
global: {
theme: "dark",
},
});
expect(output).toEqual({
global_theming_selection: "dark",
global: {},
});
});
it.each([{}, null])(
"doesn't touch it if global state looks like: '%s'",
async (globalState) => {
const output = await runMigrator(sut, {
global: globalState,
});
expect(output).toEqual({
global: globalState,
});
},
);
});
describe("rollback", () => {
it("migrates state provider theme back to original location when no global", async () => {
const output = await runMigrator(
sut,
{
global_theming_selection: "disk",
},
"rollback",
);
expect(output).toEqual({
global: {
theme: "disk",
},
});
});
it("migrates state provider theme back to legacy location when there is an existing global object", async () => {
const output = await runMigrator(
sut,
{
global_theming_selection: "disk",
global: {
other: "stuff",
},
},
"rollback",
);
expect(output).toEqual({
global: {
theme: "disk",
other: "stuff",
},
});
});
it("does nothing if no theme in state provider location", async () => {
const output = await runMigrator(sut, {}, "rollback");
expect(output).toEqual({});
});
});
});

View File

@@ -0,0 +1,31 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedGlobal = { theme?: string };
const THEME_SELECTION: KeyDefinitionLike = {
key: "selection",
stateDefinition: { name: "theming" },
};
export class MoveThemeToStateProviderMigrator extends Migrator<34, 35> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyGlobalState = await helper.get<ExpectedGlobal>("global");
const theme = legacyGlobalState?.theme;
if (theme != null) {
await helper.setToGlobal(THEME_SELECTION, theme);
delete legacyGlobalState.theme;
await helper.set("global", legacyGlobalState);
}
}
async rollback(helper: MigrationHelper): Promise<void> {
const theme = await helper.getFromGlobal<string>(THEME_SELECTION);
if (theme != null) {
const legacyGlobal = (await helper.get<ExpectedGlobal>("global")) ?? {};
legacyGlobal.theme = theme;
await helper.set("global", legacyGlobal);
await helper.removeFromGlobal(THEME_SELECTION);
}
}
}

View File

@@ -0,0 +1,142 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { VaultSettingsKeyMigrator } from "./36-move-show-card-and-identity-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
dontShowCardsCurrentTab: true,
dontShowIdentitiesCurrentTab: true,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_vaultSettings_showCardsCurrentTab": true,
"user_user-1_vaultSettings_showIdentitiesCurrentTab": true,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
const vaultSettingsStateDefinition: {
stateDefinition: StateDefinitionLike;
} = {
stateDefinition: {
name: "vaultSettings",
},
};
describe("VaultSettingsKeyMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: VaultSettingsKeyMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 35);
sut = new VaultSettingsKeyMigrator(35, 36);
});
it("should remove dontShowCardsCurrentTab and dontShowIdentitiesCurrentTab from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set showCardsCurrentTab and showIdentitiesCurrentTab values for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(2);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...vaultSettingsStateDefinition, key: "showCardsCurrentTab" },
false,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...vaultSettingsStateDefinition, key: "showIdentitiesCurrentTab" },
false,
);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 36);
sut = new VaultSettingsKeyMigrator(35, 36);
});
it("should null out new values for each account", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(2);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...vaultSettingsStateDefinition, key: "showCardsCurrentTab" },
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
{ ...vaultSettingsStateDefinition, key: "showIdentitiesCurrentTab" },
null,
);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
otherStuff: "otherStuff2",
dontShowCardsCurrentTab: false,
dontShowIdentitiesCurrentTab: false,
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,107 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountState = {
settings?: {
dontShowCardsCurrentTab?: boolean;
dontShowIdentitiesCurrentTab?: boolean;
};
};
const vaultSettingsStateDefinition: {
stateDefinition: StateDefinitionLike;
} = {
stateDefinition: {
name: "vaultSettings",
},
};
export class VaultSettingsKeyMigrator extends Migrator<35, 36> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
async function migrateAccount(userId: string, account: ExpectedAccountState): Promise<void> {
let updateAccount = false;
const accountSettings = account?.settings;
if (accountSettings?.dontShowCardsCurrentTab != null) {
await helper.setToUser(
userId,
{ ...vaultSettingsStateDefinition, key: "showCardsCurrentTab" },
!accountSettings.dontShowCardsCurrentTab,
);
delete account.settings.dontShowCardsCurrentTab;
updateAccount = true;
}
if (accountSettings?.dontShowIdentitiesCurrentTab != null) {
await helper.setToUser(
userId,
{ ...vaultSettingsStateDefinition, key: "showIdentitiesCurrentTab" },
!accountSettings.dontShowIdentitiesCurrentTab,
);
delete account.settings.dontShowIdentitiesCurrentTab;
updateAccount = true;
}
if (updateAccount) {
await helper.set(userId, account);
}
}
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise<void> {
let updateAccount = false;
let settings = account?.settings ?? {};
const showCardsCurrentTab = await helper.getFromUser<boolean>(userId, {
...vaultSettingsStateDefinition,
key: "showCardsCurrentTab",
});
const showIdentitiesCurrentTab = await helper.getFromUser<boolean>(userId, {
...vaultSettingsStateDefinition,
key: "showIdentitiesCurrentTab",
});
if (showCardsCurrentTab != null) {
// invert the value to match the new naming convention
settings = { ...settings, dontShowCardsCurrentTab: !showCardsCurrentTab };
await helper.setToUser(
userId,
{ ...vaultSettingsStateDefinition, key: "showCardsCurrentTab" },
null,
);
updateAccount = true;
}
if (showIdentitiesCurrentTab != null) {
// invert the value to match the new naming convention
settings = { ...settings, dontShowIdentitiesCurrentTab: !showIdentitiesCurrentTab };
await helper.setToUser(
userId,
{ ...vaultSettingsStateDefinition, key: "showIdentitiesCurrentTab" },
null,
);
updateAccount = true;
}
if (updateAccount) {
await helper.set(userId, { ...account, settings });
}
}
}
}

View File

@@ -0,0 +1,143 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper, runMigrator } from "../migration-helper.spec";
import { AvatarColorMigrator } from "./37-move-avatar-color-to-state-providers";
function rollbackJSON() {
return {
authenticatedAccounts: ["user-1", "user-2"],
"user_user-1_avatar_avatarColor": "#ff0000",
"user_user-2_avatar_avatarColor": "#cccccc",
"user-1": {
settings: {
extra: "data",
},
extra: "data",
},
"user-2": {
settings: {
extra: "data",
},
extra: "data",
},
};
}
describe("AvatarColorMigrator", () => {
const migrator = new AvatarColorMigrator(36, 37);
it("should migrate the avatarColor property from the account settings object to a user StorageKey", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user-1", "user-2"] as const,
"user-1": {
settings: {
avatarColor: "#ff0000",
extra: "data",
},
extra: "data",
},
"user-2": {
settings: {
avatarColor: "#cccccc",
extra: "data",
},
extra: "data",
},
});
expect(output).toEqual({
authenticatedAccounts: ["user-1", "user-2"],
"user_user-1_avatar_avatarColor": "#ff0000",
"user_user-2_avatar_avatarColor": "#cccccc",
"user-1": {
settings: {
extra: "data",
},
extra: "data",
},
"user-2": {
settings: {
extra: "data",
},
extra: "data",
},
});
});
it("should handle missing parts", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user-1", "user-2"],
global: {
extra: "data",
},
"user-1": {
extra: "data",
settings: {
extra: "data",
},
},
"user-2": null,
});
expect(output).toEqual({
authenticatedAccounts: ["user-1", "user-2"],
global: {
extra: "data",
},
"user-1": {
extra: "data",
settings: {
extra: "data",
},
},
"user-2": null,
});
});
describe("rollback", () => {
let helper: MockProxy<MigrationHelper>;
let sut: AvatarColorMigrator;
const keyDefinitionLike = {
key: "avatarColor",
stateDefinition: {
name: "avatar",
},
};
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 37);
sut = new AvatarColorMigrator(36, 37);
});
it("should null out the avatarColor user StorageKey for each account", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(2);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, null);
expect(helper.setToUser).toHaveBeenCalledWith("user-2", keyDefinitionLike, null);
});
it("should add the avatarColor property back to the account settings object", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
avatarColor: "#ff0000",
extra: "data",
},
extra: "data",
});
expect(helper.set).toHaveBeenCalledWith("user-2", {
settings: {
avatarColor: "#cccccc",
extra: "data",
},
extra: "data",
});
});
});
});

View File

@@ -0,0 +1,59 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountState = {
settings?: { avatarColor?: string };
};
const AVATAR_COLOR_STATE: StateDefinitionLike = { name: "avatar" };
const AVATAR_COLOR_KEY: KeyDefinitionLike = {
key: "avatarColor",
stateDefinition: AVATAR_COLOR_STATE,
};
export class AvatarColorMigrator extends Migrator<36, 37> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyAccounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all(
legacyAccounts.map(async ({ userId, account }) => {
// Move account avatarColor
if (account?.settings?.avatarColor != null) {
await helper.setToUser(userId, AVATAR_COLOR_KEY, account.settings.avatarColor);
// Delete old account avatarColor property
delete account?.settings?.avatarColor;
await helper.set(userId, account);
}
}),
);
}
async rollback(helper: MigrationHelper): Promise<void> {
async function rollbackUser(userId: string, account: ExpectedAccountState) {
let updatedAccount = false;
const userAvatarColor = await helper.getFromUser<string>(userId, AVATAR_COLOR_KEY);
if (userAvatarColor) {
if (!account) {
account = {};
}
updatedAccount = true;
account.settings.avatarColor = userAvatarColor;
await helper.setToUser(userId, AVATAR_COLOR_KEY, null);
}
if (updatedAccount) {
await helper.set(userId, account);
}
}
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
}
}

View File

@@ -0,0 +1,300 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
ACCESS_TOKEN_DISK,
REFRESH_TOKEN_DISK,
API_KEY_CLIENT_ID_DISK,
API_KEY_CLIENT_SECRET_DISK,
TokenServiceStateProviderMigrator,
} from "./38-migrate-token-svc-to-state-provider";
// Represents data in state service pre-migration
function preMigrationJson() {
return {
global: {
twoFactorToken: "twoFactorToken",
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2", "user3"],
user1: {
tokens: {
accessToken: "accessToken",
refreshToken: "refreshToken",
otherStuff: "overStuff2",
},
profile: {
apiKeyClientId: "apiKeyClientId",
email: "user1Email",
otherStuff: "overStuff3",
},
keys: {
apiKeyClientSecret: "apiKeyClientSecret",
otherStuff: "overStuff4",
},
otherStuff: "otherStuff5",
},
user2: {
tokens: {
// no tokens to migrate
otherStuff: "overStuff2",
},
profile: {
// no apiKeyClientId to migrate
otherStuff: "overStuff3",
email: "user2Email",
},
keys: {
// no apiKeyClientSecret to migrate
otherStuff: "overStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
// User specific state provider data
// use pattern user_{userId}_{stateDefinitionName}_{keyDefinitionKey} for user data
// User1 migrated data
user_user1_token_accessToken: "accessToken",
user_user1_token_refreshToken: "refreshToken",
user_user1_token_apiKeyClientId: "apiKeyClientId",
user_user1_token_apiKeyClientSecret: "apiKeyClientSecret",
// User2 migrated data
user_user2_token_accessToken: null as any,
user_user2_token_refreshToken: null as any,
user_user2_token_apiKeyClientId: null as any,
user_user2_token_apiKeyClientSecret: null as any,
// Global state provider data
// use pattern global_{stateDefinitionName}_{keyDefinitionKey} for global data
global_tokenDiskLocal_emailTwoFactorTokenRecord: {
user1Email: "twoFactorToken",
user2Email: "twoFactorToken",
},
global: {
// no longer has twoFactorToken
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2", "user3"],
user1: {
tokens: {
otherStuff: "overStuff2",
},
profile: {
email: "user1Email",
otherStuff: "overStuff3",
},
keys: {
otherStuff: "overStuff4",
},
otherStuff: "otherStuff5",
},
user2: {
tokens: {
otherStuff: "overStuff2",
},
profile: {
email: "user2Email",
otherStuff: "overStuff3",
},
keys: {
otherStuff: "overStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("TokenServiceStateProviderMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: TokenServiceStateProviderMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(preMigrationJson(), 37);
sut = new TokenServiceStateProviderMigrator(37, 38);
});
describe("Session storage", () => {
it("should remove state service data from all accounts that have it", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user1", {
tokens: {
otherStuff: "overStuff2",
},
profile: {
email: "user1Email",
otherStuff: "overStuff3",
},
keys: {
otherStuff: "overStuff4",
},
otherStuff: "otherStuff5",
});
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).not.toHaveBeenCalledWith("user2", any());
expect(helper.set).not.toHaveBeenCalledWith("user3", any());
});
it("should migrate data to state providers for defined accounts that have the data", async () => {
await sut.migrate(helper);
// Two factor Token Migration
expect(helper.setToGlobal).toHaveBeenLastCalledWith(
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
{
user1Email: "twoFactorToken",
user2Email: "twoFactorToken",
},
);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToUser).toHaveBeenCalledWith("user1", ACCESS_TOKEN_DISK, "accessToken");
expect(helper.setToUser).toHaveBeenCalledWith("user1", REFRESH_TOKEN_DISK, "refreshToken");
expect(helper.setToUser).toHaveBeenCalledWith(
"user1",
API_KEY_CLIENT_ID_DISK,
"apiKeyClientId",
);
expect(helper.setToUser).toHaveBeenCalledWith(
"user1",
API_KEY_CLIENT_SECRET_DISK,
"apiKeyClientSecret",
);
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", ACCESS_TOKEN_DISK, any());
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", REFRESH_TOKEN_DISK, any());
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", API_KEY_CLIENT_ID_DISK, any());
expect(helper.setToUser).not.toHaveBeenCalledWith(
"user2",
API_KEY_CLIENT_SECRET_DISK,
any(),
);
// Expect that we didn't migrate anything to user 3
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", ACCESS_TOKEN_DISK, any());
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", REFRESH_TOKEN_DISK, any());
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", API_KEY_CLIENT_ID_DISK, any());
expect(helper.setToUser).not.toHaveBeenCalledWith(
"user3",
API_KEY_CLIENT_SECRET_DISK,
any(),
);
});
});
describe("Local storage", () => {
beforeEach(() => {
helper = mockMigrationHelper(preMigrationJson(), 37, "web-disk-local");
});
it("should remove state service data from all accounts that have it", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user1", {
tokens: {
otherStuff: "overStuff2",
},
profile: {
email: "user1Email",
otherStuff: "overStuff3",
},
keys: {
otherStuff: "overStuff4",
},
otherStuff: "otherStuff5",
});
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).not.toHaveBeenCalledWith("user2", any());
expect(helper.set).not.toHaveBeenCalledWith("user3", any());
});
it("should not migrate any data to local storage", async () => {
await sut.migrate(helper);
expect(helper.setToUser).not.toHaveBeenCalled();
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 38);
sut = new TokenServiceStateProviderMigrator(37, 38);
});
it("should null out newly migrated entries in state provider framework", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
null,
);
expect(helper.setToUser).toHaveBeenCalledWith("user1", ACCESS_TOKEN_DISK, null);
expect(helper.setToUser).toHaveBeenCalledWith("user1", REFRESH_TOKEN_DISK, null);
expect(helper.setToUser).toHaveBeenCalledWith("user1", API_KEY_CLIENT_ID_DISK, null);
expect(helper.setToUser).toHaveBeenCalledWith("user1", API_KEY_CLIENT_SECRET_DISK, null);
expect(helper.setToUser).toHaveBeenCalledWith("user2", ACCESS_TOKEN_DISK, null);
expect(helper.setToUser).toHaveBeenCalledWith("user2", REFRESH_TOKEN_DISK, null);
expect(helper.setToUser).toHaveBeenCalledWith("user2", API_KEY_CLIENT_ID_DISK, null);
expect(helper.setToUser).toHaveBeenCalledWith("user2", API_KEY_CLIENT_SECRET_DISK, null);
expect(helper.setToUser).toHaveBeenCalledWith("user3", ACCESS_TOKEN_DISK, null);
expect(helper.setToUser).toHaveBeenCalledWith("user3", REFRESH_TOKEN_DISK, null);
expect(helper.setToUser).toHaveBeenCalledWith("user3", API_KEY_CLIENT_ID_DISK, null);
expect(helper.setToUser).toHaveBeenCalledWith("user3", API_KEY_CLIENT_SECRET_DISK, null);
});
it("should add back data to all accounts that had migrated data (only user 1)", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user1", {
tokens: {
accessToken: "accessToken",
refreshToken: "refreshToken",
otherStuff: "overStuff2",
},
profile: {
apiKeyClientId: "apiKeyClientId",
email: "user1Email",
otherStuff: "overStuff3",
},
keys: {
apiKeyClientSecret: "apiKeyClientSecret",
otherStuff: "overStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should add back the global twoFactorToken", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("global", {
twoFactorToken: "twoFactorToken",
otherStuff: "otherStuff1",
});
});
it("should not add data back if data wasn't migrated or acct doesn't exist", async () => {
await sut.rollback(helper);
// no data to add back for user2 (acct exists but no migrated data) and user3 (no acct)
expect(helper.set).not.toHaveBeenCalledWith("user2", any());
expect(helper.set).not.toHaveBeenCalledWith("user3", any());
});
});
});

View File

@@ -0,0 +1,245 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { Migrator } from "../migrator";
// Types to represent data as it is stored in JSON
type ExpectedAccountType = {
tokens?: {
accessToken?: string;
refreshToken?: string;
};
profile?: {
apiKeyClientId?: string;
email?: string;
};
keys?: {
apiKeyClientSecret?: string;
};
};
type ExpectedGlobalType = {
twoFactorToken?: string;
};
export const EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL: KeyDefinitionLike = {
key: "emailTwoFactorTokenRecord",
stateDefinition: {
name: "tokenDiskLocal",
},
};
const TOKEN_STATE_DEF_LIKE: StateDefinitionLike = {
name: "token",
};
export const ACCESS_TOKEN_DISK: KeyDefinitionLike = {
key: "accessToken", // matches KeyDefinition.key
stateDefinition: TOKEN_STATE_DEF_LIKE,
};
export const REFRESH_TOKEN_DISK: KeyDefinitionLike = {
key: "refreshToken",
stateDefinition: TOKEN_STATE_DEF_LIKE,
};
export const API_KEY_CLIENT_ID_DISK: KeyDefinitionLike = {
key: "apiKeyClientId",
stateDefinition: TOKEN_STATE_DEF_LIKE,
};
export const API_KEY_CLIENT_SECRET_DISK: KeyDefinitionLike = {
key: "apiKeyClientSecret",
stateDefinition: TOKEN_STATE_DEF_LIKE,
};
export class TokenServiceStateProviderMigrator extends Migrator<37, 38> {
async migrate(helper: MigrationHelper): Promise<void> {
// Move global data
const globalData = await helper.get<ExpectedGlobalType>("global");
// Create new global record for 2FA token that we can accumulate data in
const emailTwoFactorTokenRecord = {};
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(
userId: string,
account: ExpectedAccountType | undefined,
globalTwoFactorToken: string | undefined,
emailTwoFactorTokenRecord: Record<string, string>,
): Promise<void> {
let updatedAccount = false;
// migrate 2FA token from global to user state
// Due to the existing implmentation, n users on the same device share the same global state value for 2FA token.
// So, we will just migrate it to all users to keep it valid for whichever was the user that set it previously.
// Note: don't bother migrating 2FA Token if user account or email is undefined
const email = account?.profile?.email;
if (globalTwoFactorToken != undefined && account != undefined && email != undefined) {
emailTwoFactorTokenRecord[email] = globalTwoFactorToken;
// Note: don't set updatedAccount to true here as we aren't updating
// the legacy user state, just migrating a global state to a new user state
}
// Migrate access token
const existingAccessToken = account?.tokens?.accessToken;
if (existingAccessToken != null) {
// Only migrate data that exists
if (helper.type !== "web-disk-local") {
// only migrate access token to session storage - never local.
await helper.setToUser(userId, ACCESS_TOKEN_DISK, existingAccessToken);
}
delete account.tokens.accessToken;
updatedAccount = true;
}
// Migrate refresh token
const existingRefreshToken = account?.tokens?.refreshToken;
if (existingRefreshToken != null) {
if (helper.type !== "web-disk-local") {
// only migrate refresh token to session storage - never local.
await helper.setToUser(userId, REFRESH_TOKEN_DISK, existingRefreshToken);
}
delete account.tokens.refreshToken;
updatedAccount = true;
}
// Migrate API key client id
const existingApiKeyClientId = account?.profile?.apiKeyClientId;
if (existingApiKeyClientId != null) {
if (helper.type !== "web-disk-local") {
// only migrate client id to session storage - never local.
await helper.setToUser(userId, API_KEY_CLIENT_ID_DISK, existingApiKeyClientId);
}
delete account.profile.apiKeyClientId;
updatedAccount = true;
}
// Migrate API key client secret
const existingApiKeyClientSecret = account?.keys?.apiKeyClientSecret;
if (existingApiKeyClientSecret != null) {
if (helper.type !== "web-disk-local") {
// only migrate client secret to session storage - never local.
await helper.setToUser(userId, API_KEY_CLIENT_SECRET_DISK, existingApiKeyClientSecret);
}
delete account.keys.apiKeyClientSecret;
updatedAccount = true;
}
if (updatedAccount) {
// Save the migrated account only if it was updated
await helper.set(userId, account);
}
}
await Promise.all([
...accounts.map(({ userId, account }) =>
migrateAccount(userId, account, globalData?.twoFactorToken, emailTwoFactorTokenRecord),
),
]);
// Save the global 2FA token record
await helper.setToGlobal(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, emailTwoFactorTokenRecord);
// Delete global data
delete globalData?.twoFactorToken;
await helper.set("global", globalData);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
// Since we migrated the global 2FA token to all users, we need to rollback the 2FA token for all users
// but we only need to set it to the global state once
// Go through accounts and find the first user that has a non-null email and 2FA token
let migratedTwoFactorToken: string | null = null;
for (const { account } of accounts) {
const email = account?.profile?.email;
if (email == null) {
continue;
}
const emailTwoFactorTokenRecord: Record<string, string> = await helper.getFromGlobal(
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
);
migratedTwoFactorToken = emailTwoFactorTokenRecord[email];
if (migratedTwoFactorToken != null) {
break;
}
}
if (migratedTwoFactorToken != null) {
let legacyGlobal = await helper.get<ExpectedGlobalType>("global");
if (!legacyGlobal) {
legacyGlobal = {};
}
legacyGlobal.twoFactorToken = migratedTwoFactorToken;
await helper.set("global", legacyGlobal);
}
// delete global 2FA token record
await helper.setToGlobal(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, null);
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
let updatedLegacyAccount = false;
// Rollback access token
const migratedAccessToken = await helper.getFromUser<string>(userId, ACCESS_TOKEN_DISK);
if (account?.tokens && migratedAccessToken != null) {
account.tokens.accessToken = migratedAccessToken;
updatedLegacyAccount = true;
}
await helper.setToUser(userId, ACCESS_TOKEN_DISK, null);
// Rollback refresh token
const migratedRefreshToken = await helper.getFromUser<string>(userId, REFRESH_TOKEN_DISK);
if (account?.tokens && migratedRefreshToken != null) {
account.tokens.refreshToken = migratedRefreshToken;
updatedLegacyAccount = true;
}
await helper.setToUser(userId, REFRESH_TOKEN_DISK, null);
// Rollback API key client id
const migratedApiKeyClientId = await helper.getFromUser<string>(
userId,
API_KEY_CLIENT_ID_DISK,
);
if (account?.profile && migratedApiKeyClientId != null) {
account.profile.apiKeyClientId = migratedApiKeyClientId;
updatedLegacyAccount = true;
}
await helper.setToUser(userId, API_KEY_CLIENT_ID_DISK, null);
// Rollback API key client secret
const migratedApiKeyClientSecret = await helper.getFromUser<string>(
userId,
API_KEY_CLIENT_SECRET_DISK,
);
if (account?.keys && migratedApiKeyClientSecret != null) {
account.keys.apiKeyClientSecret = migratedApiKeyClientSecret;
updatedLegacyAccount = true;
}
await helper.setToUser(userId, API_KEY_CLIENT_SECRET_DISK, null);
if (updatedLegacyAccount) {
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,126 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
MoveBillingAccountProfileMigrator,
} from "./39-move-billing-account-profile-to-state-providers";
const exampleJSON = () => ({
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
profile: {
hasPremiumPersonally: true,
hasPremiumFromOrganization: false,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
otherStuff: "otherStuff4",
},
});
const rollbackJSON = () => ({
"user_user-1_billing_accountProfile": {
hasPremiumPersonally: true,
hasPremiumFromOrganization: false,
},
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
profile: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
otherStuff: "otherStuff4",
},
});
describe("MoveBillingAccountProfileToStateProviders migrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MoveBillingAccountProfileMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 39);
sut = new MoveBillingAccountProfileMigrator(38, 39);
});
it("removes from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
profile: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("sets hasPremiumPersonally value for account that have it", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
{ hasPremiumFromOrganization: false, hasPremiumPersonally: true },
);
});
it("should not call extra setToUser", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(1);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 39);
sut = new MoveBillingAccountProfileMigrator(38, 39);
});
it("nulls out new values", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
null,
);
});
it("adds explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
profile: {
hasPremiumPersonally: true,
hasPremiumFromOrganization: false,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it.each(["user-2", "user-3"])(
"does not restore values when accounts are not present",
async (userId) => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith(userId, any());
},
);
});
});

View File

@@ -0,0 +1,67 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
profile?: {
hasPremiumPersonally?: boolean;
hasPremiumFromOrganization?: boolean;
};
};
type ExpectedBillingAccountProfileType = {
hasPremiumPersonally: boolean;
hasPremiumFromOrganization: boolean;
};
export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION: KeyDefinitionLike = {
key: "accountProfile",
stateDefinition: {
name: "billing",
},
};
export class MoveBillingAccountProfileMigrator extends Migrator<38, 39> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
const migrateAccount = async (userId: string, account: ExpectedAccountType): Promise<void> => {
const hasPremiumPersonally = account?.profile?.hasPremiumPersonally;
const hasPremiumFromOrganization = account?.profile?.hasPremiumFromOrganization;
if (hasPremiumPersonally != null || hasPremiumFromOrganization != null) {
await helper.setToUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, {
hasPremiumPersonally: hasPremiumPersonally,
hasPremiumFromOrganization: hasPremiumFromOrganization,
});
delete account?.profile?.hasPremiumPersonally;
delete account?.profile?.hasPremiumFromOrganization;
await helper.set(userId, account);
}
};
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
const rollbackAccount = async (userId: string, account: ExpectedAccountType): Promise<void> => {
const value = await helper.getFromUser<ExpectedBillingAccountProfileType>(
userId,
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
);
if (account && value) {
account.profile = Object.assign(account.profile ?? {}, {
hasPremiumPersonally: value?.hasPremiumPersonally,
hasPremiumFromOrganization: value?.hasPremiumFromOrganization,
});
await helper.set(userId, account);
}
await helper.setToUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, null);
};
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

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

View File

@@ -0,0 +1,34 @@
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);
}
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
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 });
}
}

View File

@@ -0,0 +1,183 @@
import { any, MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { OrganizationMigrator } from "./40-move-organization-state-to-state-provider";
const testDate = new Date();
function exampleOrganization1() {
return JSON.stringify({
id: "id",
name: "name",
status: 0,
type: 0,
enabled: false,
usePolicies: false,
useGroups: false,
useDirectory: false,
useEvents: false,
useTotp: false,
use2fa: false,
useApi: false,
useSso: false,
useKeyConnector: false,
useScim: false,
useCustomPermissions: false,
useResetPassword: false,
useSecretsManager: false,
usePasswordManager: false,
useActivateAutofillPolicy: false,
selfHost: false,
usersGetPremium: false,
seats: 0,
maxCollections: 0,
ssoBound: false,
identifier: "identifier",
resetPasswordEnrolled: false,
userId: "userId",
hasPublicAndPrivateKeys: false,
providerId: "providerId",
providerName: "providerName",
isProviderUser: false,
isMember: false,
familySponsorshipFriendlyName: "fsfn",
familySponsorshipAvailable: false,
planProductType: 0,
keyConnectorEnabled: false,
keyConnectorUrl: "kcu",
accessSecretsManager: false,
limitCollectionCreationDeletion: false,
allowAdminAccessToAllCollectionItems: false,
flexibleCollections: false,
familySponsorshipLastSyncDate: testDate,
});
}
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
organizations: {
"organization-id-1": exampleOrganization1(),
"organization-id-2": {
// ...
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_organizations_organizations": {
"organization-id-1": exampleOrganization1(),
"organization-id-2": {
// ...
},
},
"user_user-2_organizations_organizations": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("OrganizationMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: OrganizationMigrator;
const keyDefinitionLike = {
key: "organizations",
stateDefinition: {
name: "organizations",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 40);
sut = new OrganizationMigrator(39, 40);
});
it("should remove organizations from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set organizations value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"organization-id-1": exampleOrganization1(),
"organization-id-2": {
// ...
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 40);
sut = new OrganizationMigrator(39, 40);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
organizations: {
"organization-id-1": exampleOrganization1(),
"organization-id-2": {
// ...
},
},
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,158 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
// Local declarations of `OrganizationData` and the types of it's properties.
// Duplicated to remain frozen in time when migration occurs.
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
enum OrganizationUserStatusType {
Invited = 0,
Accepted = 1,
Confirmed = 2,
Revoked = -1,
}
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
enum OrganizationUserType {
Owner = 0,
Admin = 1,
User = 2,
Manager = 3,
Custom = 4,
}
type PermissionsApi = {
accessEventLogs: boolean;
accessImportExport: boolean;
accessReports: boolean;
createNewCollections: boolean;
editAnyCollection: boolean;
deleteAnyCollection: boolean;
editAssignedCollections: boolean;
deleteAssignedCollections: boolean;
manageCiphers: boolean;
manageGroups: boolean;
manageSso: boolean;
managePolicies: boolean;
manageUsers: boolean;
manageResetPassword: boolean;
manageScim: boolean;
};
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
enum ProviderType {
Msp = 0,
Reseller = 1,
}
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
enum ProductType {
Free = 0,
Families = 1,
Teams = 2,
Enterprise = 3,
TeamsStarter = 4,
}
type OrganizationData = {
id: string;
name: string;
status: OrganizationUserStatusType;
type: OrganizationUserType;
enabled: boolean;
usePolicies: boolean;
useGroups: boolean;
useDirectory: boolean;
useEvents: boolean;
useTotp: boolean;
use2fa: boolean;
useApi: boolean;
useSso: boolean;
useKeyConnector: boolean;
useScim: boolean;
useCustomPermissions: boolean;
useResetPassword: boolean;
useSecretsManager: boolean;
usePasswordManager: boolean;
useActivateAutofillPolicy: boolean;
selfHost: boolean;
usersGetPremium: boolean;
seats: number;
maxCollections: number;
maxStorageGb?: number;
ssoBound: boolean;
identifier: string;
permissions: PermissionsApi;
resetPasswordEnrolled: boolean;
userId: string;
hasPublicAndPrivateKeys: boolean;
providerId: string;
providerName: string;
providerType?: ProviderType;
isProviderUser: boolean;
isMember: boolean;
familySponsorshipFriendlyName: string;
familySponsorshipAvailable: boolean;
planProductType: ProductType;
keyConnectorEnabled: boolean;
keyConnectorUrl: string;
familySponsorshipLastSyncDate?: Date;
familySponsorshipValidUntil?: Date;
familySponsorshipToDelete?: boolean;
accessSecretsManager: boolean;
limitCollectionCreationDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
flexibleCollections: boolean;
};
type ExpectedAccountType = {
data?: {
organizations?: Record<string, Jsonify<OrganizationData>>;
};
};
const USER_ORGANIZATIONS: KeyDefinitionLike = {
key: "organizations",
stateDefinition: {
name: "organizations",
},
};
export class OrganizationMigrator extends Migrator<39, 40> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.data?.organizations;
if (value != null) {
await helper.setToUser(userId, USER_ORGANIZATIONS, value);
delete account.data.organizations;
await helper.set(userId, account);
}
}
await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account)));
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, USER_ORGANIZATIONS);
if (account) {
account.data = Object.assign(account.data ?? {}, {
organizations: value,
});
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_ORGANIZATIONS, null);
}
await Promise.all(accounts.map(({ userId, account }) => rollbackAccount(userId, account)));
}
}

View File

@@ -0,0 +1,168 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { EventCollectionMigrator } from "./41-move-event-collection-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
eventCollection: [
{
type: 1107,
cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684",
organizationId: "278d5f91-835b-459a-a229-b11e01336d6d",
date: "2024-03-05T21:59:50.169Z",
},
{
type: 1107,
cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c",
organizationId: "278d5f91-835b-459a-a229-b11e01336d6d",
date: "2024-03-05T22:02:06.089Z",
},
],
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_eventCollection_eventCollection": [
{
type: 1107,
cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684",
organizationId: "278d5f91-835b-459a-a229-b11e01336d6d",
date: "2024-03-05T21:59:50.169Z",
},
{
type: 1107,
cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c",
organizationId: "278d5f91-835b-459a-a229-b11e01336d6d",
date: "2024-03-05T22:02:06.089Z",
},
],
"user_user-2_eventCollection_data": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("EventCollectionMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: EventCollectionMigrator;
const keyDefinitionLike = {
stateDefinition: {
name: "eventCollection",
},
key: "eventCollection",
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 40);
sut = new EventCollectionMigrator(40, 41);
});
it("should remove event collections from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set event collections for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, [
{
type: 1107,
cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684",
organizationId: "278d5f91-835b-459a-a229-b11e01336d6d",
date: "2024-03-05T21:59:50.169Z",
},
{
type: 1107,
cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c",
organizationId: "278d5f91-835b-459a-a229-b11e01336d6d",
date: "2024-03-05T22:02:06.089Z",
},
]);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 41);
sut = new EventCollectionMigrator(40, 41);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add event collection values back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalled();
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
eventCollection: [
{
type: 1107,
cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684",
organizationId: "278d5f91-835b-459a-a229-b11e01336d6d",
date: "2024-03-05T21:59:50.169Z",
},
{
type: 1107,
cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c",
organizationId: "278d5f91-835b-459a-a229-b11e01336d6d",
date: "2024-03-05T22:02:06.089Z",
},
],
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,51 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountState = {
data?: {
eventCollection?: [];
};
};
const EVENT_COLLECTION: KeyDefinitionLike = {
stateDefinition: {
name: "eventCollection",
},
key: "eventCollection",
};
export class EventCollectionMigrator extends Migrator<40, 41> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountState>();
async function migrateAccount(userId: string, account: ExpectedAccountState): Promise<void> {
const value = account?.data?.eventCollection;
if (value != null) {
await helper.setToUser(userId, EVENT_COLLECTION, value);
delete account.data.eventCollection;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountState>();
async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise<void> {
const value = await helper.getFromUser(userId, EVENT_COLLECTION);
if (account) {
account.data = Object.assign(account.data ?? {}, {
eventCollection: value,
});
await helper.set(userId, account);
}
await helper.setToUser(userId, EVENT_COLLECTION, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,108 @@
import { MockProxy } from "jest-mock-extended";
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { EnableFaviconMigrator } from "./42-move-enable-favicon-to-domain-settings-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
disableFavicon: true,
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
global_domainSettings_showFavicons: false,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
const showFaviconsKeyDefinition: KeyDefinitionLike = {
stateDefinition: {
name: "domainSettings",
},
key: "showFavicons",
};
describe("EnableFaviconMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: EnableFaviconMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 41);
sut = new EnableFaviconMigrator(41, 42);
});
it("should remove global disableFavicon", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
otherStuff: "otherStuff1",
});
});
it("should set global showFavicons", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(showFaviconsKeyDefinition, false);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 42);
sut = new EnableFaviconMigrator(41, 42);
});
it("should null global showFavicons", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(showFaviconsKeyDefinition, null);
});
it("should add global disableFavicon back", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
disableFavicon: true,
otherStuff: "otherStuff1",
});
});
});
});

View File

@@ -0,0 +1,45 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedGlobalState = {
disableFavicon?: boolean;
};
const ShowFaviconDefinition: KeyDefinitionLike = {
stateDefinition: {
name: "domainSettings",
},
key: "showFavicons",
};
export class EnableFaviconMigrator extends Migrator<41, 42> {
async migrate(helper: MigrationHelper): Promise<void> {
// global state ("disableFavicon" -> "showFavicons")
const globalState = await helper.get<ExpectedGlobalState>("global");
if (globalState?.disableFavicon != null) {
await helper.setToGlobal(ShowFaviconDefinition, !globalState.disableFavicon);
// delete `disableFavicon` from state global
delete globalState.disableFavicon;
await helper.set<ExpectedGlobalState>("global", globalState);
}
}
async rollback(helper: MigrationHelper): Promise<void> {
// global state ("showFavicons" -> "disableFavicon")
const globalState = (await helper.get<ExpectedGlobalState>("global")) || {};
const showFavicons: boolean = await helper.getFromGlobal(ShowFaviconDefinition);
if (showFavicons != null) {
await helper.set<ExpectedGlobalState>("global", {
...globalState,
disableFavicon: !showFavicons,
});
// remove the global state provider framework key for `showFavicons`
await helper.setToGlobal(ShowFaviconDefinition, null);
}
}
}

View File

@@ -0,0 +1,102 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper, runMigrator } from "../migration-helper.spec";
import { AutoConfirmFingerPrintsMigrator } from "./43-move-auto-confirm-finger-prints-to-state-provider";
function rollbackJSON() {
return {
authenticatedAccounts: ["user-1", "user-2"],
"user_user-1_organizationManagementPreferences_autoConfirmFingerPrints": true,
"user_user-2_organizationManagementPreferences_autoConfirmFingerPrints": false,
"user-1": {
settings: {
extra: "data",
},
extra: "data",
},
"user-2": {
settings: {
extra: "data",
},
extra: "data",
},
};
}
describe("AutoConfirmFingerPrintsMigrator", () => {
const migrator = new AutoConfirmFingerPrintsMigrator(42, 43);
it("should migrate the autoConfirmFingerPrints property from the account settings object to a user StorageKey", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user-1", "user-2"] as const,
"user-1": {
settings: {
autoConfirmFingerPrints: true,
extra: "data",
},
extra: "data",
},
"user-2": {
settings: {
autoConfirmFingerPrints: false,
extra: "data",
},
extra: "data",
},
});
expect(output).toEqual({
authenticatedAccounts: ["user-1", "user-2"],
"user_user-1_organizationManagementPreferences_autoConfirmFingerPrints": true,
"user_user-2_organizationManagementPreferences_autoConfirmFingerPrints": false,
"user-1": {
settings: {
extra: "data",
},
extra: "data",
},
"user-2": {
settings: {
extra: "data",
},
extra: "data",
},
});
});
describe("rollback", () => {
let helper: MockProxy<MigrationHelper>;
let sut: AutoConfirmFingerPrintsMigrator;
const keyDefinitionLike = {
key: "autoConfirmFingerPrints",
stateDefinition: {
name: "organizationManagementPreferences",
},
};
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 43);
sut = new AutoConfirmFingerPrintsMigrator(42, 43);
});
it("should null the autoConfirmFingerPrints user StorageKey for each account", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, null);
});
it("should add the autoConfirmFingerPrints property back to the account settings object", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
settings: {
autoConfirmFingerPrints: true,
extra: "data",
},
extra: "data",
});
});
});
});

View File

@@ -0,0 +1,65 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountState = {
settings?: { autoConfirmFingerPrints?: boolean };
};
const ORGANIZATION_MANAGEMENT_PREFERENCES: StateDefinitionLike = {
name: "organizationManagementPreferences",
};
const AUTO_CONFIRM_FINGERPRINTS: KeyDefinitionLike = {
key: "autoConfirmFingerPrints",
stateDefinition: ORGANIZATION_MANAGEMENT_PREFERENCES,
};
export class AutoConfirmFingerPrintsMigrator extends Migrator<42, 43> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyAccounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all(
legacyAccounts.map(async ({ userId, account }) => {
if (account?.settings?.autoConfirmFingerPrints != null) {
await helper.setToUser(
userId,
AUTO_CONFIRM_FINGERPRINTS,
account.settings.autoConfirmFingerPrints,
);
delete account?.settings?.autoConfirmFingerPrints;
await helper.set(userId, account);
}
}),
);
}
async rollback(helper: MigrationHelper): Promise<void> {
async function rollbackUser(userId: string, account: ExpectedAccountState) {
let updatedAccount = false;
const autoConfirmFingerPrints = await helper.getFromUser<boolean>(
userId,
AUTO_CONFIRM_FINGERPRINTS,
);
if (autoConfirmFingerPrints) {
if (!account) {
account = {};
}
updatedAccount = true;
account.settings.autoConfirmFingerPrints = autoConfirmFingerPrints;
await helper.setToUser(userId, AUTO_CONFIRM_FINGERPRINTS, null);
}
if (updatedAccount) {
await helper.set(userId, account);
}
}
const accounts = await helper.getAccounts<ExpectedAccountState>();
await Promise.all(accounts.map(({ userId, account }) => rollbackUser(userId, account)));
}
}

View File

@@ -0,0 +1,238 @@
import { any, MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { UserDecryptionOptionsMigrator } from "./44-move-user-decryption-options-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"],
FirstAccount: {
decryptionOptions: {
hasMasterPassword: true,
trustedDeviceOption: {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://keyconnector.bitwarden.com",
},
},
profile: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
SecondAccount: {
decryptionOptions: {
hasMasterPassword: false,
trustedDeviceOption: {
hasAdminApproval: true,
hasLoginApprovingDevice: true,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://selfhosted.bitwarden.com",
},
},
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
user_FirstAccount_decryptionOptions_userDecryptionOptions: {
hasMasterPassword: true,
trustedDeviceOption: {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://keyconnector.bitwarden.com",
},
},
user_SecondAccount_decryptionOptions_userDecryptionOptions: {
hasMasterPassword: false,
trustedDeviceOption: {
hasAdminApproval: true,
hasLoginApprovingDevice: true,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://selfhosted.bitwarden.com",
},
},
user_ThirdAccount_decryptionOptions_userDecryptionOptions: {},
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"],
FirstAccount: {
decryptionOptions: {
hasMasterPassword: true,
trustedDeviceOption: {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://keyconnector.bitwarden.com",
},
},
profile: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
SecondAccount: {
decryptionOptions: {
hasMasterPassword: false,
trustedDeviceOption: {
hasAdminApproval: true,
hasLoginApprovingDevice: true,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://selfhosted.bitwarden.com",
},
},
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("UserDecryptionOptionsMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: UserDecryptionOptionsMigrator;
const keyDefinitionLike = {
key: "decryptionOptions",
stateDefinition: {
name: "userDecryptionOptions",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 43);
sut = new UserDecryptionOptionsMigrator(43, 44);
});
it("should remove decryptionOptions from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
profile: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("SecondAccount", {
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should set decryptionOptions provider value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", keyDefinitionLike, {
hasMasterPassword: true,
trustedDeviceOption: {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://keyconnector.bitwarden.com",
},
});
expect(helper.setToUser).toHaveBeenCalledWith("SecondAccount", keyDefinitionLike, {
hasMasterPassword: false,
trustedDeviceOption: {
hasAdminApproval: true,
hasLoginApprovingDevice: true,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://selfhosted.bitwarden.com",
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 44);
sut = new UserDecryptionOptionsMigrator(43, 44);
});
it.each(["FirstAccount", "SecondAccount", "ThirdAccount"])(
"should null out new values",
async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
},
);
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
decryptionOptions: {
hasMasterPassword: true,
trustedDeviceOption: {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://keyconnector.bitwarden.com",
},
},
profile: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("SecondAccount", {
decryptionOptions: {
hasMasterPassword: false,
trustedDeviceOption: {
hasAdminApproval: true,
hasLoginApprovingDevice: true,
hasManageResetPasswordPermission: true,
},
keyConnectorOption: {
keyConnectorUrl: "https://selfhosted.bitwarden.com",
},
},
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("ThirdAccount", any());
});
});
});

View File

@@ -0,0 +1,59 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type DecryptionOptionsType = {
hasMasterPassword: boolean;
trustedDeviceOption?: {
hasAdminApproval: boolean;
hasLoginApprovingDevice: boolean;
hasManageResetPasswordPermission: boolean;
};
keyConnectorOption?: {
keyConnectorUrl: string;
};
};
type ExpectedAccountType = {
decryptionOptions?: DecryptionOptionsType;
};
const USER_DECRYPTION_OPTIONS: KeyDefinitionLike = {
key: "decryptionOptions",
stateDefinition: {
name: "userDecryptionOptions",
},
};
export class UserDecryptionOptionsMigrator extends Migrator<43, 44> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.decryptionOptions;
if (value != null) {
await helper.setToUser(userId, USER_DECRYPTION_OPTIONS, value);
delete account.decryptionOptions;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value: DecryptionOptionsType = await helper.getFromUser(
userId,
USER_DECRYPTION_OPTIONS,
);
if (account) {
account.decryptionOptions = Object.assign(account.decryptionOptions, value);
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_DECRYPTION_OPTIONS, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,164 @@
import { runMigrator } from "../migration-helper.spec";
import { MergeEnvironmentState } from "./45-merge-environment-state";
describe("MergeEnvironmentState", () => {
const migrator = new MergeEnvironmentState(44, 45);
it("can migrate all data", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user1", "user2"],
global: {
extra: "data",
},
global_environment_region: "US",
global_environment_urls: {
base: "example.com",
},
user1: {
extra: "data",
settings: {
extra: "data",
},
},
user2: {
extra: "data",
settings: {
extra: "data",
},
},
extra: "data",
user_user1_environment_region: "US",
user_user2_environment_region: "EU",
user_user1_environment_urls: {
base: "example.com",
},
user_user2_environment_urls: {
base: "other.example.com",
},
});
expect(output).toEqual({
authenticatedAccounts: ["user1", "user2"],
global: {
extra: "data",
},
global_environment_environment: {
region: "US",
urls: {
base: "example.com",
},
},
user1: {
extra: "data",
settings: {
extra: "data",
},
},
user2: {
extra: "data",
settings: {
extra: "data",
},
},
extra: "data",
user_user1_environment_environment: {
region: "US",
urls: {
base: "example.com",
},
},
user_user2_environment_environment: {
region: "EU",
urls: {
base: "other.example.com",
},
},
});
});
it("handles missing parts", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user1", "user2"],
global: {
extra: "data",
},
user1: {
extra: "data",
settings: {
extra: "data",
},
},
user2: null,
});
expect(output).toEqual({
authenticatedAccounts: ["user1", "user2"],
global: {
extra: "data",
},
user1: {
extra: "data",
settings: {
extra: "data",
},
},
user2: null,
});
});
it("can migrate only global data", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: [],
global_environment_region: "Self-Hosted",
global: {},
});
expect(output).toEqual({
authenticatedAccounts: [],
global_environment_environment: {
region: "Self-Hosted",
urls: undefined,
},
global: {},
});
});
it("can migrate only user state", async () => {
const output = await runMigrator(migrator, {
authenticatedAccounts: ["user1"] as const,
global: null,
user1: { settings: {} },
user_user1_environment_region: "Self-Hosted",
user_user1_environment_urls: {
base: "some-base-url",
api: "some-api-url",
identity: "some-identity-url",
icons: "some-icons-url",
notifications: "some-notifications-url",
events: "some-events-url",
webVault: "some-webVault-url",
keyConnector: "some-keyConnector-url",
},
});
expect(output).toEqual({
authenticatedAccounts: ["user1"] as const,
global: null,
user1: { settings: {} },
user_user1_environment_environment: {
region: "Self-Hosted",
urls: {
base: "some-base-url",
api: "some-api-url",
identity: "some-identity-url",
icons: "some-icons-url",
notifications: "some-notifications-url",
events: "some-events-url",
webVault: "some-webVault-url",
keyConnector: "some-keyConnector-url",
},
},
});
});
});

View File

@@ -0,0 +1,83 @@
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { Migrator } from "../migrator";
const ENVIRONMENT_STATE: StateDefinitionLike = { name: "environment" };
const ENVIRONMENT_REGION: KeyDefinitionLike = {
key: "region",
stateDefinition: ENVIRONMENT_STATE,
};
const ENVIRONMENT_URLS: KeyDefinitionLike = {
key: "urls",
stateDefinition: ENVIRONMENT_STATE,
};
const ENVIRONMENT_ENVIRONMENT: KeyDefinitionLike = {
key: "environment",
stateDefinition: ENVIRONMENT_STATE,
};
export class MergeEnvironmentState extends Migrator<44, 45> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<unknown>();
async function migrateAccount(userId: string, account: unknown): Promise<void> {
const region = await helper.getFromUser(userId, ENVIRONMENT_REGION);
const urls = await helper.getFromUser(userId, ENVIRONMENT_URLS);
if (region == null && urls == null) {
return;
}
await helper.setToUser(userId, ENVIRONMENT_ENVIRONMENT, {
region,
urls,
});
await helper.removeFromUser(userId, ENVIRONMENT_REGION);
await helper.removeFromUser(userId, ENVIRONMENT_URLS);
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
const region = await helper.getFromGlobal(ENVIRONMENT_REGION);
const urls = await helper.getFromGlobal(ENVIRONMENT_URLS);
if (region == null && urls == null) {
return;
}
await helper.setToGlobal(ENVIRONMENT_ENVIRONMENT, {
region,
urls,
});
await helper.removeFromGlobal(ENVIRONMENT_REGION);
await helper.removeFromGlobal(ENVIRONMENT_URLS);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<unknown>();
async function rollbackAccount(userId: string, account: unknown): Promise<void> {
const state = (await helper.getFromUser(userId, ENVIRONMENT_ENVIRONMENT)) as {
region: string;
urls: string;
} | null;
await helper.setToUser(userId, ENVIRONMENT_REGION, state?.region);
await helper.setToUser(userId, ENVIRONMENT_URLS, state?.urls);
await helper.removeFromUser(userId, ENVIRONMENT_ENVIRONMENT);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
const state = (await helper.getFromGlobal(ENVIRONMENT_ENVIRONMENT)) as {
region: string;
urls: string;
} | null;
await helper.setToGlobal(ENVIRONMENT_REGION, state?.region);
await helper.setToGlobal(ENVIRONMENT_URLS, state?.urls);
await helper.removeFromGlobal(ENVIRONMENT_ENVIRONMENT);
}
}

View File

@@ -0,0 +1,28 @@
import { runMigrator } from "../migration-helper.spec";
import { IRREVERSIBLE } from "../migrator";
import { DeleteBiometricPromptCancelledData } from "./46-delete-orphaned-biometric-prompt-data";
describe("MoveThemeToStateProviders", () => {
const sut = new DeleteBiometricPromptCancelledData(45, 46);
describe("migrate", () => {
it("deletes promptCancelled from all users", async () => {
const output = await runMigrator(sut, {
authenticatedAccounts: ["user-1", "user-2"],
"user_user-1_biometricSettings_promptCancelled": true,
"user_user-2_biometricSettings_promptCancelled": false,
});
expect(output).toEqual({
authenticatedAccounts: ["user-1", "user-2"],
});
});
});
describe("rollback", () => {
it("is irreversible", async () => {
await expect(runMigrator(sut, {}, "rollback")).rejects.toThrow(IRREVERSIBLE);
});
});
});

View File

@@ -0,0 +1,23 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { IRREVERSIBLE, Migrator } from "../migrator";
export const PROMPT_CANCELLED: KeyDefinitionLike = {
key: "promptCancelled",
stateDefinition: { name: "biometricSettings" },
};
export class DeleteBiometricPromptCancelledData extends Migrator<45, 46> {
async migrate(helper: MigrationHelper): Promise<void> {
await Promise.all(
(await helper.getAccounts()).map(async ({ userId }) => {
if (helper.getFromUser(userId, PROMPT_CANCELLED) != null) {
await helper.removeFromUser(userId, PROMPT_CANCELLED);
}
}),
);
}
async rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
}

View File

@@ -0,0 +1,116 @@
import { runMigrator } from "../migration-helper.spec";
import { MoveDesktopSettingsMigrator } from "./47-move-desktop-settings";
describe("MoveDesktopSettings", () => {
const sut = new MoveDesktopSettingsMigrator(46, 47);
it("can migrate truthy values", async () => {
const output = await runMigrator(sut, {
authenticatedAccounts: ["user1"],
global: {
window: {
width: 400,
height: 400,
displayBounds: {
height: 200,
width: 200,
x: 200,
y: 200,
},
},
enableAlwaysOnTop: true,
enableCloseToTray: true,
enableMinimizeToTray: true,
enableStartToTray: true,
enableTray: true,
openAtLogin: true,
alwaysShowDock: true,
},
user1: {
settings: {
enableAlwaysOnTop: true,
},
},
});
expect(output).toEqual({
authenticatedAccounts: ["user1"],
global: {},
global_desktopSettings_window: {
width: 400,
height: 400,
displayBounds: {
height: 200,
width: 200,
x: 200,
y: 200,
},
},
global_desktopSettings_closeToTray: true,
global_desktopSettings_minimizeToTray: true,
global_desktopSettings_startToTray: true,
global_desktopSettings_trayEnabled: true,
global_desktopSettings_openAtLogin: true,
global_desktopSettings_alwaysShowDock: true,
global_desktopSettings_alwaysOnTop: true,
user1: {
settings: {},
},
});
});
it("can migrate falsey values", async () => {
const output = await runMigrator(sut, {
authenticatedAccounts: ["user1"],
global: {
window: null,
enableCloseToTray: false,
enableMinimizeToTray: false,
enableStartToTray: false,
enableTray: false,
openAtLogin: false,
alwaysShowDock: false,
enableAlwaysOnTop: false,
},
user1: {
settings: {
enableAlwaysOnTop: false,
},
},
});
expect(output).toEqual({
authenticatedAccounts: ["user1"],
global: {},
global_desktopSettings_window: null,
global_desktopSettings_closeToTray: false,
global_desktopSettings_minimizeToTray: false,
global_desktopSettings_startToTray: false,
global_desktopSettings_trayEnabled: false,
global_desktopSettings_openAtLogin: false,
global_desktopSettings_alwaysShowDock: false,
global_desktopSettings_alwaysOnTop: false,
user1: {
settings: {},
},
});
});
it("can migrate even if none of our values are found", async () => {
//
const output = await runMigrator(sut, {
authenticatedAccounts: ["user1"] as const,
global: {
anotherSetting: "",
},
});
expect(output).toEqual({
authenticatedAccounts: ["user1"] as const,
global: {
anotherSetting: "",
},
});
});
});

View File

@@ -0,0 +1,128 @@
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedGlobalType = {
window?: object;
enableTray?: boolean;
enableMinimizeToTray?: boolean;
enableCloseToTray?: boolean;
enableStartToTray?: boolean;
openAtLogin?: boolean;
alwaysShowDock?: boolean;
enableAlwaysOnTop?: boolean;
};
type ExpectedAccountType = {
settings?: {
enableAlwaysOnTop?: boolean;
};
};
const DESKTOP_SETTINGS_STATE: StateDefinitionLike = { name: "desktopSettings" };
const WINDOW_KEY: KeyDefinitionLike = { key: "window", stateDefinition: DESKTOP_SETTINGS_STATE };
const CLOSE_TO_TRAY_KEY: KeyDefinitionLike = {
key: "closeToTray",
stateDefinition: DESKTOP_SETTINGS_STATE,
};
const MINIMIZE_TO_TRAY_KEY: KeyDefinitionLike = {
key: "minimizeToTray",
stateDefinition: DESKTOP_SETTINGS_STATE,
};
const START_TO_TRAY_KEY: KeyDefinitionLike = {
key: "startToTray",
stateDefinition: DESKTOP_SETTINGS_STATE,
};
const TRAY_ENABLED_KEY: KeyDefinitionLike = {
key: "trayEnabled",
stateDefinition: DESKTOP_SETTINGS_STATE,
};
const OPEN_AT_LOGIN_KEY: KeyDefinitionLike = {
key: "openAtLogin",
stateDefinition: DESKTOP_SETTINGS_STATE,
};
const ALWAYS_SHOW_DOCK_KEY: KeyDefinitionLike = {
key: "alwaysShowDock",
stateDefinition: DESKTOP_SETTINGS_STATE,
};
const ALWAYS_ON_TOP_KEY: KeyDefinitionLike = {
key: "alwaysOnTop",
stateDefinition: DESKTOP_SETTINGS_STATE,
};
export class MoveDesktopSettingsMigrator extends Migrator<46, 47> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyGlobal = await helper.get<ExpectedGlobalType>("global");
let updatedGlobal = false;
if (legacyGlobal?.window !== undefined) {
await helper.setToGlobal(WINDOW_KEY, legacyGlobal.window);
updatedGlobal = true;
delete legacyGlobal.window;
}
if (legacyGlobal?.enableCloseToTray != null) {
await helper.setToGlobal(CLOSE_TO_TRAY_KEY, legacyGlobal.enableCloseToTray);
updatedGlobal = true;
delete legacyGlobal.enableCloseToTray;
}
if (legacyGlobal?.enableMinimizeToTray != null) {
await helper.setToGlobal(MINIMIZE_TO_TRAY_KEY, legacyGlobal.enableMinimizeToTray);
updatedGlobal = true;
delete legacyGlobal.enableMinimizeToTray;
}
if (legacyGlobal?.enableStartToTray != null) {
await helper.setToGlobal(START_TO_TRAY_KEY, legacyGlobal.enableStartToTray);
updatedGlobal = true;
delete legacyGlobal.enableStartToTray;
}
if (legacyGlobal?.enableTray != null) {
await helper.setToGlobal(TRAY_ENABLED_KEY, legacyGlobal.enableTray);
updatedGlobal = true;
delete legacyGlobal.enableTray;
}
if (legacyGlobal?.openAtLogin != null) {
await helper.setToGlobal(OPEN_AT_LOGIN_KEY, legacyGlobal.openAtLogin);
updatedGlobal = true;
delete legacyGlobal.openAtLogin;
}
if (legacyGlobal?.alwaysShowDock != null) {
await helper.setToGlobal(ALWAYS_SHOW_DOCK_KEY, legacyGlobal.alwaysShowDock);
updatedGlobal = true;
delete legacyGlobal.alwaysShowDock;
}
if (legacyGlobal?.enableAlwaysOnTop != null) {
await helper.setToGlobal(ALWAYS_ON_TOP_KEY, legacyGlobal.enableAlwaysOnTop);
updatedGlobal = true;
delete legacyGlobal.enableAlwaysOnTop;
}
if (updatedGlobal) {
await helper.set("global", legacyGlobal);
}
async function migrateAccount(userId: string, account: ExpectedAccountType) {
// We only migrate the global setting for this, if we find it on the account object
// just delete it.
if (account?.settings?.enableAlwaysOnTop != null) {
delete account.settings.enableAlwaysOnTop;
await helper.set(userId, account);
}
}
const accounts = await helper.getAccounts<ExpectedAccountType>();
await Promise.all(accounts.map(({ userId, account }) => migrateAccount(userId, account)));
}
rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
}

View File

@@ -0,0 +1,47 @@
import { runMigrator } from "../migration-helper.spec";
import { MoveDdgToStateProviderMigrator } from "./48-move-ddg-to-state-provider";
describe("MoveDdgToStateProviderMigrator", () => {
const migrator = new MoveDdgToStateProviderMigrator(47, 48);
it("migrate", async () => {
const output = await runMigrator(migrator, {
global: {
enableDuckDuckGoBrowserIntegration: true,
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
});
expect(output).toEqual({
global_autofillSettings_enableDuckDuckGoBrowserIntegration: true,
global: {
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
});
});
it("rollback", async () => {
const output = await runMigrator(
migrator,
{
global_autofillSettings_enableDuckDuckGoBrowserIntegration: true,
global: {
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
},
"rollback",
);
expect(output).toEqual({
global: {
enableDuckDuckGoBrowserIntegration: true,
otherStuff: "otherStuff1",
},
otherStuff: "otherStuff2",
});
});
});

View File

@@ -0,0 +1,40 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedGlobal = {
enableDuckDuckGoBrowserIntegration?: boolean;
};
export const DDG_KEY: KeyDefinitionLike = {
key: "enableDuckDuckGoBrowserIntegration",
stateDefinition: {
name: "autofillSettings",
},
};
export class MoveDdgToStateProviderMigrator extends Migrator<47, 48> {
async migrate(helper: MigrationHelper): Promise<void> {
// global state
const global = await helper.get<ExpectedGlobal>("global");
if (global?.enableDuckDuckGoBrowserIntegration == null) {
return;
}
await helper.setToGlobal(DDG_KEY, global.enableDuckDuckGoBrowserIntegration);
delete global.enableDuckDuckGoBrowserIntegration;
await helper.set("global", global);
}
async rollback(helper: MigrationHelper): Promise<void> {
const enableDdg = await helper.getFromGlobal<boolean>(DDG_KEY);
if (!enableDdg) {
return;
}
const global = (await helper.get<ExpectedGlobal>("global")) ?? {};
global.enableDuckDuckGoBrowserIntegration = enableDdg;
await helper.set("global", global);
await helper.removeFromGlobal(DDG_KEY);
}
}

View File

@@ -0,0 +1,112 @@
import { runMigrator } from "../migration-helper.spec";
import { AccountServerConfigMigrator } from "./49-move-account-server-configs";
describe("AccountServerConfigMigrator", () => {
const migrator = new AccountServerConfigMigrator(48, 49);
describe("all data", () => {
function toMigrate() {
return {
authenticatedAccounts: ["user1", "user2"],
user1: {
settings: {
serverConfig: {
config: "user1 server config",
},
},
},
user2: {
settings: {
serverConfig: {
config: "user2 server config",
},
},
},
};
}
function migrated() {
return {
authenticatedAccounts: ["user1", "user2"],
user1: {
settings: {},
},
user2: {
settings: {},
},
user_user1_config_serverConfig: {
config: "user1 server config",
},
user_user2_config_serverConfig: {
config: "user2 server config",
},
};
}
function rolledBack(previous: object) {
return {
...previous,
user_user1_config_serverConfig: null as unknown,
user_user2_config_serverConfig: null as unknown,
};
}
it("migrates", async () => {
const output = await runMigrator(migrator, toMigrate(), "migrate");
expect(output).toEqual(migrated());
});
it("rolls back", async () => {
const output = await runMigrator(migrator, migrated(), "rollback");
expect(output).toEqual(rolledBack(toMigrate()));
});
});
describe("missing parts", () => {
function toMigrate() {
return {
authenticatedAccounts: ["user1", "user2"],
user1: {
settings: {
serverConfig: {
config: "user1 server config",
},
},
},
user2: null as unknown,
};
}
function migrated() {
return {
authenticatedAccounts: ["user1", "user2"],
user1: {
settings: {},
},
user2: null as unknown,
user_user1_config_serverConfig: {
config: "user1 server config",
},
};
}
function rollback(previous: object) {
return {
...previous,
user_user1_config_serverConfig: null as unknown,
};
}
it("migrates", async () => {
const output = await runMigrator(migrator, toMigrate(), "migrate");
expect(output).toEqual(migrated());
});
it("rolls back", async () => {
const output = await runMigrator(migrator, migrated(), "rollback");
expect(output).toEqual(rollback(toMigrate()));
});
});
});

View File

@@ -0,0 +1,51 @@
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { Migrator } from "../migrator";
const CONFIG_DISK: StateDefinitionLike = { name: "config" };
export const USER_SERVER_CONFIG: KeyDefinitionLike = {
stateDefinition: CONFIG_DISK,
key: "serverConfig",
};
// Note: no need to migrate global configs, they don't currently exist
type ExpectedAccountType = {
settings?: {
serverConfig?: unknown;
};
};
export class AccountServerConfigMigrator extends Migrator<48, 49> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
if (account?.settings?.serverConfig != null) {
await helper.setToUser(userId, USER_SERVER_CONFIG, account.settings.serverConfig);
delete account.settings.serverConfig;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const serverConfig = await helper.getFromUser(userId, USER_SERVER_CONFIG);
if (serverConfig) {
account ??= {};
account.settings ??= {};
account.settings.serverConfig = serverConfig;
await helper.setToUser(userId, USER_SERVER_CONFIG, null);
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

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

View File

@@ -0,0 +1,73 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
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);
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
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);
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
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 });
}
}

View File

@@ -0,0 +1,174 @@
import { MockProxy } from "jest-mock-extended";
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { KeyConnectorMigrator } from "./50-move-key-connector-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"],
FirstAccount: {
profile: {
usesKeyConnector: true,
convertAccountToKeyConnector: false,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
SecondAccount: {
profile: {
usesKeyConnector: true,
convertAccountToKeyConnector: true,
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
user_FirstAccount_keyConnector_usesKeyConnector: true,
user_FirstAccount_keyConnector_convertAccountToKeyConnector: false,
user_SecondAccount_keyConnector_usesKeyConnector: true,
user_SecondAccount_keyConnector_convertAccountToKeyConnector: true,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"],
FirstAccount: {
profile: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
SecondAccount: {
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
const usesKeyConnectorKeyDefinition: KeyDefinitionLike = {
key: "usesKeyConnector",
stateDefinition: {
name: "keyConnector",
},
};
const convertAccountToKeyConnectorKeyDefinition: KeyDefinitionLike = {
key: "convertAccountToKeyConnector",
stateDefinition: {
name: "keyConnector",
},
};
describe("KeyConnectorMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: KeyConnectorMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 50);
sut = new KeyConnectorMigrator(49, 50);
});
it("should remove usesKeyConnector and convertAccountToKeyConnector from Profile", async () => {
await sut.migrate(helper);
// Set is called 2 times even though there are 3 accounts. Since the target properties don't exist in ThirdAccount, they are not set.
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
profile: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.setToUser).toHaveBeenCalledWith(
"FirstAccount",
usesKeyConnectorKeyDefinition,
true,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"FirstAccount",
convertAccountToKeyConnectorKeyDefinition,
false,
);
expect(helper.set).toHaveBeenCalledWith("SecondAccount", {
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
expect(helper.setToUser).toHaveBeenCalledWith(
"SecondAccount",
usesKeyConnectorKeyDefinition,
true,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"SecondAccount",
convertAccountToKeyConnectorKeyDefinition,
true,
);
expect(helper.setToUser).not.toHaveBeenCalledWith("ThirdAccount");
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 50);
sut = new KeyConnectorMigrator(49, 50);
});
it("should null out new usesKeyConnector global value", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(4);
expect(helper.set).toHaveBeenCalledTimes(2);
expect(helper.setToUser).toHaveBeenCalledWith(
"FirstAccount",
usesKeyConnectorKeyDefinition,
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"FirstAccount",
convertAccountToKeyConnectorKeyDefinition,
null,
);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
profile: {
usesKeyConnector: true,
convertAccountToKeyConnector: false,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.setToUser).toHaveBeenCalledWith(
"SecondAccount",
usesKeyConnectorKeyDefinition,
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(
"SecondAccount",
convertAccountToKeyConnectorKeyDefinition,
null,
);
expect(helper.set).toHaveBeenCalledWith("SecondAccount", {
profile: {
usesKeyConnector: true,
convertAccountToKeyConnector: true,
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
});
expect(helper.setToUser).not.toHaveBeenCalledWith("ThirdAccount");
expect(helper.set).not.toHaveBeenCalledWith("ThirdAccount");
});
});
});

View File

@@ -0,0 +1,80 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
profile?: {
usesKeyConnector?: boolean;
convertAccountToKeyConnector?: boolean;
};
};
const usesKeyConnectorKeyDefinition: KeyDefinitionLike = {
key: "usesKeyConnector",
stateDefinition: {
name: "keyConnector",
},
};
const convertAccountToKeyConnectorKeyDefinition: KeyDefinitionLike = {
key: "convertAccountToKeyConnector",
stateDefinition: {
name: "keyConnector",
},
};
export class KeyConnectorMigrator extends Migrator<49, 50> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const usesKeyConnector = account?.profile?.usesKeyConnector;
const convertAccountToKeyConnector = account?.profile?.convertAccountToKeyConnector;
if (usesKeyConnector == null && convertAccountToKeyConnector == null) {
return;
}
if (usesKeyConnector != null) {
await helper.setToUser(userId, usesKeyConnectorKeyDefinition, usesKeyConnector);
delete account.profile.usesKeyConnector;
}
if (convertAccountToKeyConnector != null) {
await helper.setToUser(
userId,
convertAccountToKeyConnectorKeyDefinition,
convertAccountToKeyConnector,
);
delete account.profile.convertAccountToKeyConnector;
}
await helper.set(userId, account);
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const usesKeyConnector: boolean = await helper.getFromUser(
userId,
usesKeyConnectorKeyDefinition,
);
const convertAccountToKeyConnector: boolean = await helper.getFromUser(
userId,
convertAccountToKeyConnectorKeyDefinition,
);
if (usesKeyConnector == null && convertAccountToKeyConnector == null) {
return;
}
if (usesKeyConnector != null) {
account.profile.usesKeyConnector = usesKeyConnector;
await helper.setToUser(userId, usesKeyConnectorKeyDefinition, null);
}
if (convertAccountToKeyConnector != null) {
account.profile.convertAccountToKeyConnector = convertAccountToKeyConnector;
await helper.setToUser(userId, convertAccountToKeyConnectorKeyDefinition, null);
}
await helper.set(userId, account);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,81 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper, runMigrator } from "../migration-helper.spec";
import { RememberedEmailMigrator } from "./51-move-remembered-email-to-state-providers";
function rollbackJSON() {
return {
global: {
extra: "data",
},
global_loginEmail_storedEmail: "user@example.com",
};
}
describe("RememberedEmailMigrator", () => {
const migrator = new RememberedEmailMigrator(50, 51);
describe("migrate", () => {
it("should migrate the rememberedEmail property from the legacy global object to a global StorageKey as 'global_loginEmail_storedEmail'", async () => {
const output = await runMigrator(migrator, {
global: {
rememberedEmail: "user@example.com",
extra: "data", // Represents a global property that should persist after migration
},
});
expect(output).toEqual({
global: {
extra: "data",
},
global_loginEmail_storedEmail: "user@example.com",
});
});
it("should remove the rememberedEmail property from the legacy global object", async () => {
const output = await runMigrator(migrator, {
global: {
rememberedEmail: "user@example.com",
},
});
expect(output.global).not.toHaveProperty("rememberedEmail");
});
});
describe("rollback", () => {
let helper: MockProxy<MigrationHelper>;
let sut: RememberedEmailMigrator;
const keyDefinitionLike = {
key: "storedEmail",
stateDefinition: {
name: "loginEmail",
},
};
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 51);
sut = new RememberedEmailMigrator(50, 51);
});
it("should null out the storedEmail global StorageKey", async () => {
await sut.rollback(helper);
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
expect(helper.setToGlobal).toHaveBeenCalledWith(keyDefinitionLike, null);
});
it("should add the rememberedEmail property back to legacy global object", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("global", {
rememberedEmail: "user@example.com",
extra: "data",
});
});
});
});

View File

@@ -0,0 +1,46 @@
import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedGlobalState = { rememberedEmail?: string };
const LOGIN_EMAIL_STATE: StateDefinitionLike = { name: "loginEmail" };
const STORED_EMAIL: KeyDefinitionLike = {
key: "storedEmail",
stateDefinition: LOGIN_EMAIL_STATE,
};
export class RememberedEmailMigrator extends Migrator<50, 51> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyGlobal = await helper.get<ExpectedGlobalState>("global");
// Move global data
if (legacyGlobal?.rememberedEmail != null) {
await helper.setToGlobal(STORED_EMAIL, legacyGlobal.rememberedEmail);
}
// Delete legacy global data
delete legacyGlobal?.rememberedEmail;
await helper.set("global", legacyGlobal);
}
async rollback(helper: MigrationHelper): Promise<void> {
let legacyGlobal = await helper.get<ExpectedGlobalState>("global");
let updatedLegacyGlobal = false;
const globalStoredEmail = await helper.getFromGlobal<string>(STORED_EMAIL);
if (globalStoredEmail) {
if (!legacyGlobal) {
legacyGlobal = {};
}
updatedLegacyGlobal = true;
legacyGlobal.rememberedEmail = globalStoredEmail;
await helper.setToGlobal(STORED_EMAIL, null);
}
if (updatedLegacyGlobal) {
await helper.set("global", legacyGlobal);
}
}
}

View File

@@ -0,0 +1,35 @@
import { runMigrator } from "../migration-helper.spec";
import { DeleteInstalledVersion } from "./52-delete-installed-version";
describe("DeleteInstalledVersion", () => {
const sut = new DeleteInstalledVersion(51, 52);
describe("migrate", () => {
it("can delete data if there", async () => {
const output = await runMigrator(sut, {
authenticatedAccounts: ["user1"],
global: {
installedVersion: "2024.1.1",
},
});
expect(output).toEqual({
authenticatedAccounts: ["user1"],
global: {},
});
});
it("will run if installed version is not there", async () => {
const output = await runMigrator(sut, {
authenticatedAccounts: ["user1"],
global: {},
});
expect(output).toEqual({
authenticatedAccounts: ["user1"],
global: {},
});
});
});
});

View File

@@ -0,0 +1,19 @@
import { MigrationHelper } from "../migration-helper";
import { IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedGlobal = {
installedVersion?: string;
};
export class DeleteInstalledVersion extends Migrator<51, 52> {
async migrate(helper: MigrationHelper): Promise<void> {
const legacyGlobal = await helper.get<ExpectedGlobal>("global");
if (legacyGlobal?.installedVersion != null) {
delete legacyGlobal.installedVersion;
await helper.set("global", legacyGlobal);
}
}
rollback(helper: MigrationHelper): Promise<void> {
throw IRREVERSIBLE;
}
}

View File

@@ -0,0 +1,171 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
DEVICE_KEY,
DeviceTrustServiceStateProviderMigrator,
SHOULD_TRUST_DEVICE,
} from "./53-migrate-device-trust-svc-to-state-providers";
// Represents data in state service pre-migration
function preMigrationJson() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2", "user3"],
user1: {
keys: {
deviceKey: {
keyB64: "user1_deviceKey",
},
otherStuff: "overStuff2",
},
settings: {
trustDeviceChoiceForDecryption: true,
otherStuff: "overStuff3",
},
otherStuff: "otherStuff4",
},
user2: {
keys: {
// no device key
otherStuff: "otherStuff5",
},
settings: {
// no trust device choice
otherStuff: "overStuff6",
},
otherStuff: "otherStuff7",
},
};
}
function rollbackJSON() {
return {
// use pattern user_{userId}_{stateDefinitionName}_{keyDefinitionKey} for each user
// User1 migrated data
user_user1_deviceTrust_deviceKey: {
keyB64: "user1_deviceKey",
},
user_user1_deviceTrust_shouldTrustDevice: true,
// User2 does not have migrated data
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user1", "user2", "user3"],
user1: {
keys: {
otherStuff: "overStuff2",
},
settings: {
otherStuff: "overStuff3",
},
otherStuff: "otherStuff4",
},
user2: {
keys: {
otherStuff: "otherStuff5",
},
settings: {
otherStuff: "overStuff6",
},
otherStuff: "otherStuff6",
},
};
}
describe("DeviceTrustServiceStateProviderMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: DeviceTrustServiceStateProviderMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(preMigrationJson(), 52);
sut = new DeviceTrustServiceStateProviderMigrator(52, 53);
});
// it should remove deviceKey and trustDeviceChoiceForDecryption from all accounts
it("should remove deviceKey and trustDeviceChoiceForDecryption from all accounts that have it", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user1", {
keys: {
otherStuff: "overStuff2",
},
settings: {
otherStuff: "overStuff3",
},
otherStuff: "otherStuff4",
});
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).not.toHaveBeenCalledWith("user2", any());
expect(helper.set).not.toHaveBeenCalledWith("user3", any());
});
it("should migrate deviceKey and trustDeviceChoiceForDecryption to state providers for accounts that have the data", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user1", DEVICE_KEY, {
keyB64: "user1_deviceKey",
});
expect(helper.setToUser).toHaveBeenCalledWith("user1", SHOULD_TRUST_DEVICE, true);
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", DEVICE_KEY, any());
expect(helper.setToUser).not.toHaveBeenCalledWith("user2", SHOULD_TRUST_DEVICE, any());
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", DEVICE_KEY, any());
expect(helper.setToUser).not.toHaveBeenCalledWith("user3", SHOULD_TRUST_DEVICE, any());
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 53);
sut = new DeviceTrustServiceStateProviderMigrator(52, 53);
});
it("should null out newly migrated entries in state provider framework", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user1", DEVICE_KEY, null);
expect(helper.setToUser).toHaveBeenCalledWith("user1", SHOULD_TRUST_DEVICE, null);
expect(helper.setToUser).toHaveBeenCalledWith("user2", DEVICE_KEY, null);
expect(helper.setToUser).toHaveBeenCalledWith("user2", SHOULD_TRUST_DEVICE, null);
expect(helper.setToUser).toHaveBeenCalledWith("user3", DEVICE_KEY, null);
expect(helper.setToUser).toHaveBeenCalledWith("user3", SHOULD_TRUST_DEVICE, null);
});
it("should add back deviceKey and trustDeviceChoiceForDecryption to all accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("user1", {
keys: {
deviceKey: {
keyB64: "user1_deviceKey",
},
otherStuff: "overStuff2",
},
settings: {
trustDeviceChoiceForDecryption: true,
otherStuff: "overStuff3",
},
otherStuff: "otherStuff4",
});
});
it("should not add data back if data wasn't migrated or acct doesn't exist", async () => {
await sut.rollback(helper);
// no data to add back for user2 (acct exists but no migrated data) and user3 (no acct)
expect(helper.set).not.toHaveBeenCalledWith("user2", any());
expect(helper.set).not.toHaveBeenCalledWith("user3", any());
});
});
});

View File

@@ -0,0 +1,97 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
// Types to represent data as it is stored in JSON
type DeviceKeyJsonType = {
keyB64: string;
};
type ExpectedAccountType = {
keys?: {
deviceKey?: DeviceKeyJsonType;
};
settings?: {
trustDeviceChoiceForDecryption?: boolean;
};
};
export const DEVICE_KEY: KeyDefinitionLike = {
key: "deviceKey", // matches KeyDefinition.key in DeviceTrustService
stateDefinition: {
name: "deviceTrust", // matches StateDefinition.name in StateDefinitions
},
};
export const SHOULD_TRUST_DEVICE: KeyDefinitionLike = {
key: "shouldTrustDevice",
stateDefinition: {
name: "deviceTrust",
},
};
export class DeviceTrustServiceStateProviderMigrator extends Migrator<52, 53> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
let updatedAccount = false;
// Migrate deviceKey
const existingDeviceKey = account?.keys?.deviceKey;
if (existingDeviceKey != null) {
// Only migrate data that exists
await helper.setToUser(userId, DEVICE_KEY, existingDeviceKey);
delete account.keys.deviceKey;
updatedAccount = true;
}
// Migrate shouldTrustDevice
const existingShouldTrustDevice = account?.settings?.trustDeviceChoiceForDecryption;
if (existingShouldTrustDevice != null) {
await helper.setToUser(userId, SHOULD_TRUST_DEVICE, existingShouldTrustDevice);
delete account.settings.trustDeviceChoiceForDecryption;
updatedAccount = true;
}
if (updatedAccount) {
// Save the migrated account
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
// Rollback deviceKey
const migratedDeviceKey: DeviceKeyJsonType = await helper.getFromUser(userId, DEVICE_KEY);
if (account?.keys && migratedDeviceKey != null) {
account.keys.deviceKey = migratedDeviceKey;
await helper.set(userId, account);
}
await helper.setToUser(userId, DEVICE_KEY, null);
// Rollback shouldTrustDevice
const migratedShouldTrustDevice = await helper.getFromUser<boolean>(
userId,
SHOULD_TRUST_DEVICE,
);
if (account?.settings && migratedShouldTrustDevice != null) {
account.settings.trustDeviceChoiceForDecryption = migratedShouldTrustDevice;
await helper.set(userId, account);
}
await helper.setToUser(userId, SHOULD_TRUST_DEVICE, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,236 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { SendMigrator } from "./54-move-encrypted-sends";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
sends: {
encrypted: {
"2ebadc23-e101-471b-bf2d-b125015337a0": {
id: "2ebadc23-e101-471b-bf2d-b125015337a0",
accessId: "I9y6LgHhG0e_LbElAVM3oA",
deletionDate: "2024-03-07T20:35:03Z",
disabled: false,
hideEmail: false,
key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=",
name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=",
text: {
hidden: false,
text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=",
},
type: 0,
},
"3b31c20d-b783-4912-9170-b12501555398": {
id: "3b31c20d-b783-4912-9170-b12501555398",
accessId: "DcIxO4O3EkmRcLElAVVTmA",
deletionDate: "2024-03-07T20:42:43Z",
disabled: false,
hideEmail: false,
key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=",
name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=",
text: {
hidden: false,
text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=",
},
type: 0,
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_send_sends": {
"2ebadc23-e101-471b-bf2d-b125015337a0": {
id: "2ebadc23-e101-471b-bf2d-b125015337a0",
accessId: "I9y6LgHhG0e_LbElAVM3oA",
deletionDate: "2024-03-07T20:35:03Z",
disabled: false,
hideEmail: false,
key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=",
name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=",
text: {
hidden: false,
text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=",
},
type: 0,
},
"3b31c20d-b783-4912-9170-b12501555398": {
id: "3b31c20d-b783-4912-9170-b12501555398",
accessId: "DcIxO4O3EkmRcLElAVVTmA",
deletionDate: "2024-03-07T20:42:43Z",
disabled: false,
hideEmail: false,
key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=",
name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=",
text: {
hidden: false,
text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=",
},
type: 0,
},
},
"user_user-2_send_data": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("SendMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: SendMigrator;
const keyDefinitionLike = {
stateDefinition: {
name: "send",
},
key: "sends",
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 53);
sut = new SendMigrator(53, 54);
});
it("should remove encrypted sends from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set encrypted sends for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"2ebadc23-e101-471b-bf2d-b125015337a0": {
id: "2ebadc23-e101-471b-bf2d-b125015337a0",
accessId: "I9y6LgHhG0e_LbElAVM3oA",
deletionDate: "2024-03-07T20:35:03Z",
disabled: false,
hideEmail: false,
key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=",
name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=",
text: {
hidden: false,
text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=",
},
type: 0,
},
"3b31c20d-b783-4912-9170-b12501555398": {
id: "3b31c20d-b783-4912-9170-b12501555398",
accessId: "DcIxO4O3EkmRcLElAVVTmA",
deletionDate: "2024-03-07T20:42:43Z",
disabled: false,
hideEmail: false,
key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=",
name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=",
text: {
hidden: false,
text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=",
},
type: 0,
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 54);
sut = new SendMigrator(53, 54);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add encrypted send values back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalled();
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
sends: {
encrypted: {
"2ebadc23-e101-471b-bf2d-b125015337a0": {
id: "2ebadc23-e101-471b-bf2d-b125015337a0",
accessId: "I9y6LgHhG0e_LbElAVM3oA",
deletionDate: "2024-03-07T20:35:03Z",
disabled: false,
hideEmail: false,
key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=",
name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=",
text: {
hidden: false,
text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=",
},
type: 0,
},
"3b31c20d-b783-4912-9170-b12501555398": {
id: "3b31c20d-b783-4912-9170-b12501555398",
accessId: "DcIxO4O3EkmRcLElAVVTmA",
deletionDate: "2024-03-07T20:42:43Z",
disabled: false,
hideEmail: false,
key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=",
name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=",
text: {
hidden: false,
text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=",
},
type: 0,
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,71 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum SendType {
Text = 0,
File = 1,
}
type SendData = {
id: string;
accessId: string;
};
type ExpectedSendState = {
data?: {
sends?: {
encrypted?: Record<string, SendData>;
};
};
};
const ENCRYPTED_SENDS: KeyDefinitionLike = {
stateDefinition: {
name: "send",
},
key: "sends",
};
/**
* Only encrypted sends are stored on disk. Only the encrypted items need to be
* migrated from the previous sends state data.
*/
export class SendMigrator extends Migrator<53, 54> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedSendState>();
async function migrateAccount(userId: string, account: ExpectedSendState): Promise<void> {
const value = account?.data?.sends?.encrypted;
if (value != null) {
await helper.setToUser(userId, ENCRYPTED_SENDS, value);
delete account.data.sends;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedSendState>();
async function rollbackAccount(userId: string, account: ExpectedSendState): Promise<void> {
const value = await helper.getFromUser(userId, ENCRYPTED_SENDS);
if (account) {
account.data = Object.assign(account.data ?? {}, {
sends: {
encrypted: value,
},
});
await helper.set(userId, account);
}
await helper.setToUser(userId, ENCRYPTED_SENDS, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,210 @@
import { any, MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
FORCE_SET_PASSWORD_REASON_DEFINITION,
MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION,
MASTER_KEY_HASH_DEFINITION,
MoveMasterKeyStateToProviderMigrator,
} from "./55-move-master-key-state-to-provider";
function preMigrationState() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"],
// prettier-ignore
"FirstAccount": {
profile: {
forceSetPasswordReason: "FirstAccount_forceSetPasswordReason",
keyHash: "FirstAccount_keyHash",
otherStuff: "overStuff2",
},
keys: {
masterKeyEncryptedUserKey: "FirstAccount_masterKeyEncryptedUserKey",
},
otherStuff: "otherStuff3",
},
// prettier-ignore
"SecondAccount": {
profile: {
forceSetPasswordReason: "SecondAccount_forceSetPasswordReason",
keyHash: "SecondAccount_keyHash",
otherStuff: "otherStuff4",
},
keys: {
masterKeyEncryptedUserKey: "SecondAccount_masterKeyEncryptedUserKey",
},
otherStuff: "otherStuff5",
},
// prettier-ignore
"ThirdAccount": {
profile: {
otherStuff: "otherStuff6",
},
},
};
}
function postMigrationState() {
return {
user_FirstAccount_masterPassword_forceSetPasswordReason: "FirstAccount_forceSetPasswordReason",
user_FirstAccount_masterPassword_masterKeyHash: "FirstAccount_keyHash",
user_FirstAccount_masterPassword_masterKeyEncryptedUserKey:
"FirstAccount_masterKeyEncryptedUserKey",
user_SecondAccount_masterPassword_forceSetPasswordReason:
"SecondAccount_forceSetPasswordReason",
user_SecondAccount_masterPassword_masterKeyHash: "SecondAccount_keyHash",
user_SecondAccount_masterPassword_masterKeyEncryptedUserKey:
"SecondAccount_masterKeyEncryptedUserKey",
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount"],
// prettier-ignore
"FirstAccount": {
profile: {
otherStuff: "overStuff2",
},
otherStuff: "otherStuff3",
},
// prettier-ignore
"SecondAccount": {
profile: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
// prettier-ignore
"ThirdAccount": {
profile: {
otherStuff: "otherStuff6",
},
},
};
}
describe("MoveForceSetPasswordReasonToStateProviderMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MoveMasterKeyStateToProviderMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(preMigrationState(), 54);
sut = new MoveMasterKeyStateToProviderMigrator(54, 55);
});
it("should remove properties from existing accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
profile: {
otherStuff: "overStuff2",
},
keys: {},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("SecondAccount", {
profile: {
otherStuff: "otherStuff4",
},
keys: {},
otherStuff: "otherStuff5",
});
});
it("should set properties for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith(
"FirstAccount",
FORCE_SET_PASSWORD_REASON_DEFINITION,
"FirstAccount_forceSetPasswordReason",
);
expect(helper.setToUser).toHaveBeenCalledWith(
"FirstAccount",
MASTER_KEY_HASH_DEFINITION,
"FirstAccount_keyHash",
);
expect(helper.setToUser).toHaveBeenCalledWith(
"FirstAccount",
MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION,
"FirstAccount_masterKeyEncryptedUserKey",
);
expect(helper.setToUser).toHaveBeenCalledWith(
"SecondAccount",
FORCE_SET_PASSWORD_REASON_DEFINITION,
"SecondAccount_forceSetPasswordReason",
);
expect(helper.setToUser).toHaveBeenCalledWith(
"SecondAccount",
MASTER_KEY_HASH_DEFINITION,
"SecondAccount_keyHash",
);
expect(helper.setToUser).toHaveBeenCalledWith(
"SecondAccount",
MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION,
"SecondAccount_masterKeyEncryptedUserKey",
);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(postMigrationState(), 55);
sut = new MoveMasterKeyStateToProviderMigrator(54, 55);
});
it.each(["FirstAccount", "SecondAccount"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(
userId,
FORCE_SET_PASSWORD_REASON_DEFINITION,
null,
);
expect(helper.setToUser).toHaveBeenCalledWith(userId, MASTER_KEY_HASH_DEFINITION, null);
});
it("should add explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
profile: {
forceSetPasswordReason: "FirstAccount_forceSetPasswordReason",
keyHash: "FirstAccount_keyHash",
otherStuff: "overStuff2",
},
keys: {
masterKeyEncryptedUserKey: "FirstAccount_masterKeyEncryptedUserKey",
},
otherStuff: "otherStuff3",
});
expect(helper.set).toHaveBeenCalledWith("SecondAccount", {
profile: {
forceSetPasswordReason: "SecondAccount_forceSetPasswordReason",
keyHash: "SecondAccount_keyHash",
otherStuff: "otherStuff4",
},
keys: {
masterKeyEncryptedUserKey: "SecondAccount_masterKeyEncryptedUserKey",
},
otherStuff: "otherStuff5",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("ThirdAccount", any());
});
});
});

View File

@@ -0,0 +1,113 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
keys?: {
masterKeyEncryptedUserKey?: string;
};
profile?: {
forceSetPasswordReason?: number;
keyHash?: string;
};
};
export const FORCE_SET_PASSWORD_REASON_DEFINITION: KeyDefinitionLike = {
key: "forceSetPasswordReason",
stateDefinition: {
name: "masterPassword",
},
};
export const MASTER_KEY_HASH_DEFINITION: KeyDefinitionLike = {
key: "masterKeyHash",
stateDefinition: {
name: "masterPassword",
},
};
export const MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION: KeyDefinitionLike = {
key: "masterKeyEncryptedUserKey",
stateDefinition: {
name: "masterPassword",
},
};
export class MoveMasterKeyStateToProviderMigrator extends Migrator<54, 55> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const forceSetPasswordReason = account?.profile?.forceSetPasswordReason;
if (forceSetPasswordReason != null) {
await helper.setToUser(
userId,
FORCE_SET_PASSWORD_REASON_DEFINITION,
forceSetPasswordReason,
);
delete account.profile.forceSetPasswordReason;
await helper.set(userId, account);
}
const masterKeyHash = account?.profile?.keyHash;
if (masterKeyHash != null) {
await helper.setToUser(userId, MASTER_KEY_HASH_DEFINITION, masterKeyHash);
delete account.profile.keyHash;
await helper.set(userId, account);
}
const masterKeyEncryptedUserKey = account?.keys?.masterKeyEncryptedUserKey;
if (masterKeyEncryptedUserKey != null) {
await helper.setToUser(
userId,
MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION,
masterKeyEncryptedUserKey,
);
delete account.keys.masterKeyEncryptedUserKey;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const forceSetPasswordReason = await helper.getFromUser(
userId,
FORCE_SET_PASSWORD_REASON_DEFINITION,
);
const masterKeyHash = await helper.getFromUser(userId, MASTER_KEY_HASH_DEFINITION);
const masterKeyEncryptedUserKey = await helper.getFromUser(
userId,
MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION,
);
if (account != null) {
if (forceSetPasswordReason != null) {
account.profile = Object.assign(account.profile ?? {}, {
forceSetPasswordReason,
});
}
if (masterKeyHash != null) {
account.profile = Object.assign(account.profile ?? {}, {
keyHash: masterKeyHash,
});
}
if (masterKeyEncryptedUserKey != null) {
account.keys = Object.assign(account.keys ?? {}, {
masterKeyEncryptedUserKey,
});
}
await helper.set(userId, account);
}
await helper.setToUser(userId, FORCE_SET_PASSWORD_REASON_DEFINITION, null);
await helper.setToUser(userId, MASTER_KEY_HASH_DEFINITION, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -0,0 +1,138 @@
import { MockProxy } from "jest-mock-extended";
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { AuthRequestMigrator } from "./56-move-auth-requests";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount"],
FirstAccount: {
settings: {
otherStuff: "otherStuff2",
approveLoginRequests: true,
},
otherStuff: "otherStuff3",
adminAuthRequest: {
id: "id1",
privateKey: "privateKey1",
},
},
SecondAccount: {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
user_FirstAccount_authRequestLocal_adminAuthRequest: {
id: "id1",
privateKey: "privateKey1",
},
user_FirstAccount_authRequestLocal_acceptAuthRequests: true,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount"],
FirstAccount: {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
SecondAccount: {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
const ADMIN_AUTH_REQUEST_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "adminAuthRequest",
};
const ACCEPT_AUTH_REQUESTS_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "acceptAuthRequests",
};
describe("AuthRequestMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: AuthRequestMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 55);
sut = new AuthRequestMigrator(55, 56);
});
it("removes the existing adminAuthRequest and approveLoginRequests", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).not.toHaveBeenCalledWith("SecondAccount");
});
it("sets the adminAuthRequest and approveLoginRequests under the new key definitions", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ADMIN_AUTH_REQUEST_KEY, {
id: "id1",
privateKey: "privateKey1",
});
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ACCEPT_AUTH_REQUESTS_KEY, true);
expect(helper.setToUser).not.toHaveBeenCalledWith("SecondAccount");
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 56);
sut = new AuthRequestMigrator(55, 56);
});
it("nulls the new adminAuthRequest and acceptAuthRequests values", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ADMIN_AUTH_REQUEST_KEY, null);
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ACCEPT_AUTH_REQUESTS_KEY, null);
});
it("sets back the adminAuthRequest and approveLoginRequests under old account object", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
adminAuthRequest: {
id: "id1",
privateKey: "privateKey1",
},
settings: {
otherStuff: "otherStuff2",
approveLoginRequests: true,
},
otherStuff: "otherStuff3",
});
});
});
});

View File

@@ -0,0 +1,106 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type AdminAuthRequestStorable = {
id: string;
privateKey: string;
};
type ExpectedAccountType = {
adminAuthRequest?: AdminAuthRequestStorable;
settings?: {
approveLoginRequests?: boolean;
};
};
const ADMIN_AUTH_REQUEST_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "adminAuthRequest",
};
const ACCEPT_AUTH_REQUESTS_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "acceptAuthRequests",
};
export class AuthRequestMigrator extends Migrator<55, 56> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
let updatedAccount = false;
// Migrate admin auth request
const existingAdminAuthRequest = account?.adminAuthRequest;
if (existingAdminAuthRequest != null) {
await helper.setToUser(userId, ADMIN_AUTH_REQUEST_KEY, existingAdminAuthRequest);
delete account.adminAuthRequest;
updatedAccount = true;
}
// Migrate approve login requests
const existingApproveLoginRequests = account?.settings?.approveLoginRequests;
if (existingApproveLoginRequests != null) {
await helper.setToUser(userId, ACCEPT_AUTH_REQUESTS_KEY, existingApproveLoginRequests);
delete account.settings.approveLoginRequests;
updatedAccount = true;
}
if (updatedAccount) {
// Save the migrated account
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
let updatedAccount = false;
// Rollback admin auth request
const migratedAdminAuthRequest: AdminAuthRequestStorable = await helper.getFromUser(
userId,
ADMIN_AUTH_REQUEST_KEY,
);
if (migratedAdminAuthRequest != null) {
account.adminAuthRequest = migratedAdminAuthRequest;
updatedAccount = true;
}
await helper.setToUser(userId, ADMIN_AUTH_REQUEST_KEY, null);
// Rollback approve login requests
const migratedAcceptAuthRequest: boolean = await helper.getFromUser(
userId,
ACCEPT_AUTH_REQUESTS_KEY,
);
if (migratedAcceptAuthRequest != null) {
account.settings = Object.assign(account.settings ?? {}, {
approveLoginRequests: migratedAcceptAuthRequest,
});
updatedAccount = true;
}
await helper.setToUser(userId, ACCEPT_AUTH_REQUESTS_KEY, null);
if (updatedAccount) {
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

Some files were not shown because too many files have changed in this diff Show More