mirror of
https://github.com/bitwarden/browser
synced 2025-12-18 17:23:37 +00:00
PM-3585 Improve state migrations (#5009)
* WIP: safer state migrations Co-authored-by: Justin Baur <justindbaur@users.noreply.github.com> * Add min version check and remove old migrations Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com> * Add rollback and version checking * Add state version move migration * Expand tests and improve typing for Migrations * Remove StateMigration Service * Rewrite version 5 and 6 migrations * Add all but initial migration to supported migrations * Handle stateVersion location in migrator update versions * Move to unique migrations directory * Disallow imports outside of state-migrations * Lint and test fixes * Do not run migrations if we cannot determine state * Fix desktop background StateService build * Document Migration builder class * Add debug logging to migrations * Comment on migrator overrides * Use specific property names * `npm run prettier` 🤖 * Insert new migration * Set stateVersion when creating new globals object * PR comments * Fix migrate imports * Move migration building into `migrate` function * Export current version from migration definitions * Move file version concerns to migrator * Update migrate spec to reflect new version requirements * Fix import paths * Prefer unique state data * Remove unnecessary async * Prefer to not use `any` --------- Co-authored-by: Justin Baur <justindbaur@users.noreply.github.com> Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Used for testing migration, which requires import
|
||||
import { TokenService } from "../../auth/services/token.service";
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { FixPremiumMigrator } from "./3-fix-premium";
|
||||
|
||||
function migrateExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 2,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
profile: {
|
||||
otherStuff: "otherStuff2",
|
||||
hasPremiumPersonally: null as boolean,
|
||||
},
|
||||
tokens: {
|
||||
otherStuff: "otherStuff3",
|
||||
accessToken: "accessToken",
|
||||
},
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
profile: {
|
||||
otherStuff: "otherStuff5",
|
||||
hasPremiumPersonally: true,
|
||||
},
|
||||
tokens: {
|
||||
otherStuff: "otherStuff6",
|
||||
accessToken: "accessToken",
|
||||
},
|
||||
otherStuff: "otherStuff7",
|
||||
},
|
||||
otherStuff: "otherStuff8",
|
||||
};
|
||||
}
|
||||
|
||||
jest.mock("../../auth/services/token.service", () => ({
|
||||
TokenService: {
|
||||
decodeToken: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("FixPremiumMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: FixPremiumMigrator;
|
||||
const decodeTokenSpy = TokenService.decodeToken as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(migrateExampleJSON());
|
||||
sut = new FixPremiumMigrator(2, 3);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
it("should migrate hasPremiumPersonally", async () => {
|
||||
decodeTokenSpy.mockResolvedValueOnce({ premium: true });
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
profile: {
|
||||
otherStuff: "otherStuff2",
|
||||
hasPremiumPersonally: true,
|
||||
},
|
||||
tokens: {
|
||||
otherStuff: "otherStuff3",
|
||||
accessToken: "accessToken",
|
||||
},
|
||||
otherStuff: "otherStuff4",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not migrate if decode throws", async () => {
|
||||
decodeTokenSpy.mockRejectedValueOnce(new Error("test"));
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not migrate if decode returns null", async () => {
|
||||
decodeTokenSpy.mockResolvedValueOnce(null);
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVersion", () => {
|
||||
it("should update version", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 3,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
48
libs/common/src/state-migrations/migrations/3-fix-premium.ts
Normal file
48
libs/common/src/state-migrations/migrations/3-fix-premium.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Used for token decoding, which are valid for days. We want the latest
|
||||
import { TokenService } from "../../auth/services/token.service";
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Migrator, IRREVERSIBLE, Direction } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = {
|
||||
profile?: { hasPremiumPersonally?: boolean };
|
||||
tokens?: { accessToken?: string };
|
||||
};
|
||||
|
||||
export class FixPremiumMigrator extends Migrator<2, 3> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function fixPremium(userId: string, account: ExpectedAccountType) {
|
||||
if (account?.profile?.hasPremiumPersonally === null && account.tokens?.accessToken != null) {
|
||||
let decodedToken: { premium: boolean };
|
||||
try {
|
||||
decodedToken = await TokenService.decodeToken(account.tokens.accessToken);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (decodedToken?.premium == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
account.profile.hasPremiumPersonally = decodedToken?.premium;
|
||||
return helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => fixPremium(userId, account)));
|
||||
}
|
||||
|
||||
rollback(helper: MigrationHelper): Promise<void> {
|
||||
throw IRREVERSIBLE;
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
|
||||
// it is nested inside a global object.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
const global: Record<string, unknown> = (await helper.get("global")) || {};
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { RemoveEverBeenUnlockedMigrator } from "./4-remove-ever-been-unlocked";
|
||||
|
||||
function migrateExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 3,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
profile: {
|
||||
otherStuff: "otherStuff2",
|
||||
everBeenUnlocked: true,
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
profile: {
|
||||
otherStuff: "otherStuff4",
|
||||
everBeenUnlocked: false,
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
otherStuff: "otherStuff6",
|
||||
};
|
||||
}
|
||||
|
||||
describe("RemoveEverBeenUnlockedMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: RemoveEverBeenUnlockedMigrator;
|
||||
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(migrateExampleJSON());
|
||||
sut = new RemoveEverBeenUnlockedMigrator(3, 4);
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
it("should remove everBeenUnlocked from profile", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(2);
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
profile: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
|
||||
profile: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVersion", () => {
|
||||
it("should update version up", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 4,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = { profile?: { everBeenUnlocked?: boolean } };
|
||||
|
||||
export class RemoveEverBeenUnlockedMigrator extends Migrator<3, 4> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function removeEverBeenUnlocked(userId: string, account: ExpectedAccountType) {
|
||||
if (account?.profile?.everBeenUnlocked != null) {
|
||||
delete account.profile.everBeenUnlocked;
|
||||
return helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(accounts.map(({ userId, account }) => removeEverBeenUnlocked(userId, account)));
|
||||
}
|
||||
|
||||
rollback(helper: MigrationHelper): Promise<void> {
|
||||
throw IRREVERSIBLE;
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
|
||||
// it is nested inside a global object.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { AddKeyTypeToOrgKeysMigrator } from "./5-add-key-type-to-org-keys";
|
||||
|
||||
function migrateExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 4,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: "orgOneEncKey",
|
||||
orgTwoId: "orgTwoEncKey",
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 5,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: {
|
||||
type: "organization",
|
||||
key: "orgOneEncKey",
|
||||
},
|
||||
orgTwoId: {
|
||||
type: "organization",
|
||||
key: "orgTwoEncKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("AddKeyTypeToOrgKeysMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: AddKeyTypeToOrgKeysMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(migrateExampleJSON());
|
||||
sut = new AddKeyTypeToOrgKeysMigrator(4, 5);
|
||||
});
|
||||
|
||||
it("should add organization type to organization keys", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: {
|
||||
type: "organization",
|
||||
key: "orgOneEncKey",
|
||||
},
|
||||
orgTwoId: {
|
||||
type: "organization",
|
||||
key: "orgTwoEncKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should update version", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 5,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackExampleJSON());
|
||||
sut = new AddKeyTypeToOrgKeysMigrator(4, 5);
|
||||
});
|
||||
|
||||
it("should remove type from orgainzation keys", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
keys: {
|
||||
organizationKeys: {
|
||||
encrypted: {
|
||||
orgOneId: "orgOneEncKey",
|
||||
orgTwoId: "orgTwoEncKey",
|
||||
},
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should update version down", async () => {
|
||||
await sut.updateVersion(helper, "down");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 4,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Direction, Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = { keys?: { organizationKeys?: { encrypted: Record<string, string> } } };
|
||||
type NewAccountType = {
|
||||
keys?: {
|
||||
organizationKeys?: { encrypted: Record<string, { type: "organization"; key: string }> };
|
||||
};
|
||||
};
|
||||
|
||||
export class AddKeyTypeToOrgKeysMigrator extends Migrator<4, 5> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts();
|
||||
|
||||
async function updateOrgKey(userId: string, account: ExpectedAccountType) {
|
||||
const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted;
|
||||
if (encryptedOrgKeys == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrgKeys: Record<string, { type: "organization"; key: string }> = {};
|
||||
|
||||
Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => {
|
||||
newOrgKeys[orgId] = {
|
||||
type: "organization",
|
||||
key: encKey,
|
||||
};
|
||||
});
|
||||
(account as any).keys.organizationKeys.encrypted = newOrgKeys;
|
||||
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
|
||||
Promise.all(accounts.map(({ userId, account }) => updateOrgKey(userId, account)));
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts();
|
||||
|
||||
async function updateOrgKey(userId: string, account: NewAccountType) {
|
||||
const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted;
|
||||
if (encryptedOrgKeys == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrgKeys: Record<string, string> = {};
|
||||
|
||||
Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => {
|
||||
newOrgKeys[orgId] = encKey.key;
|
||||
});
|
||||
(account as any).keys.organizationKeys.encrypted = newOrgKeys;
|
||||
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
|
||||
Promise.all(accounts.map(async ({ userId, account }) => updateOrgKey(userId, account)));
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
|
||||
// it is nested inside a global object.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { RemoveLegacyEtmKeyMigrator } from "./6-remove-legacy-etm-key";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 5,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
keys: {
|
||||
legacyEtmKey: "legacyEtmKey",
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
keys: {
|
||||
legacyEtmKey: "legacyEtmKey",
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("RemoveLegacyEtmKeyMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: RemoveLegacyEtmKeyMigrator;
|
||||
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON());
|
||||
sut = new RemoveLegacyEtmKeyMigrator(5, 6);
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
it("should remove legacyEtmKey from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
keys: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
|
||||
keys: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
it("should throw", async () => {
|
||||
await expect(sut.rollback(helper)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVersion", () => {
|
||||
it("should update version up", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 6,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = { keys?: { legacyEtmKey?: string } };
|
||||
|
||||
export class RemoveLegacyEtmKeyMigrator extends Migrator<5, 6> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
|
||||
async function updateAccount(userId: string, account: ExpectedAccountType) {
|
||||
if (account?.keys?.legacyEtmKey) {
|
||||
delete account.keys.legacyEtmKey;
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(accounts.map(({ userId, account }) => updateAccount(userId, account)));
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
throw IRREVERSIBLE;
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
|
||||
// it is nested inside a global object.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { MockProxy, any, matches } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { MoveBiometricAutoPromptToAccount } from "./7-move-biometric-auto-prompt-to-account";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 6,
|
||||
noAutoPromptBiometrics: true,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: [
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760",
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
|
||||
"fd005ea6-a16a-45ef-ba4a-a194269bfd73",
|
||||
],
|
||||
"c493ed01-4e08-4e88-abc7-332f380ca760": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("RemoveLegacyEtmKeyMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: MoveBiometricAutoPromptToAccount;
|
||||
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON());
|
||||
sut = new MoveBiometricAutoPromptToAccount(6, 7);
|
||||
});
|
||||
|
||||
describe("migrate", () => {
|
||||
it("should remove noAutoPromptBiometrics from global", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
otherStuff: "otherStuff1",
|
||||
stateVersion: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it("should set disableAutoBiometricsPrompt to true on all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", {
|
||||
settings: {
|
||||
disableAutoBiometricsPrompt: true,
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", {
|
||||
settings: {
|
||||
disableAutoBiometricsPrompt: true,
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not set disableAutoBiometricsPrompt to true on accounts if noAutoPromptBiometrics is false", async () => {
|
||||
const json = exampleJSON();
|
||||
json.global.noAutoPromptBiometrics = false;
|
||||
helper = mockMigrationHelper(json);
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).not.toHaveBeenCalledWith(
|
||||
matches((s) => s != "global"),
|
||||
any()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
it("should throw", async () => {
|
||||
await expect(sut.rollback(helper)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateVersion", () => {
|
||||
it("should update version up", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith(
|
||||
"global",
|
||||
Object.assign({}, exampleJSON().global, {
|
||||
stateVersion: 7,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Direction, IRREVERSIBLE, Migrator } from "../migrator";
|
||||
|
||||
type ExpectedAccountType = { settings?: { disableAutoBiometricsPrompt?: boolean } };
|
||||
|
||||
export class MoveBiometricAutoPromptToAccount extends Migrator<6, 7> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const global = await helper.get<{ noAutoPromptBiometrics?: boolean }>("global");
|
||||
const noAutoPromptBiometrics = global?.noAutoPromptBiometrics ?? false;
|
||||
|
||||
const accounts = await helper.getAccounts<ExpectedAccountType>();
|
||||
async function updateAccount(userId: string, account: ExpectedAccountType) {
|
||||
if (account == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (noAutoPromptBiometrics) {
|
||||
account.settings = Object.assign(account?.settings ?? {}, {
|
||||
disableAutoBiometricsPrompt: true,
|
||||
});
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
|
||||
delete global.noAutoPromptBiometrics;
|
||||
|
||||
await Promise.all([
|
||||
...accounts.map(({ userId, account }) => updateAccount(userId, account)),
|
||||
helper.set("global", global),
|
||||
]);
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
throw IRREVERSIBLE;
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but for this version
|
||||
// it is nested inside a global object.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { MoveStateVersionMigrator } from "./8-move-state-version";
|
||||
|
||||
function migrateExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
stateVersion: 6,
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackExampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
stateVersion: 7,
|
||||
otherStuff: "otherStuff2",
|
||||
};
|
||||
}
|
||||
|
||||
describe("moveStateVersion", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: MoveStateVersionMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(migrateExampleJSON());
|
||||
sut = new MoveStateVersionMigrator(7, 8);
|
||||
});
|
||||
|
||||
it("should move state version to root", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("stateVersion", 6);
|
||||
});
|
||||
|
||||
it("should remove state version from global", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw if state version not found", async () => {
|
||||
helper.get.mockReturnValue({ otherStuff: "otherStuff1" } as any);
|
||||
await expect(sut.migrate(helper)).rejects.toThrow(
|
||||
"Migration failed, state version not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("should update version up", async () => {
|
||||
await sut.updateVersion(helper, "up");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("stateVersion", 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackExampleJSON());
|
||||
sut = new MoveStateVersionMigrator(7, 8);
|
||||
});
|
||||
|
||||
it("should move state version to global", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 7,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("stateVersion", undefined);
|
||||
});
|
||||
|
||||
it("should update version down", async () => {
|
||||
await sut.updateVersion(helper, "down");
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(1);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
stateVersion: 7,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { JsonObject } from "type-fest";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { Direction, Migrator } from "../migrator";
|
||||
|
||||
export class MoveStateVersionMigrator extends Migrator<7, 8> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const global = await helper.get<{ stateVersion: number }>("global");
|
||||
if (global.stateVersion) {
|
||||
await helper.set("stateVersion", global.stateVersion);
|
||||
delete global.stateVersion;
|
||||
await helper.set("global", global);
|
||||
} else {
|
||||
throw new Error("Migration failed, state version not found");
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const version = await helper.get<number>("stateVersion");
|
||||
const global = await helper.get<JsonObject>("global");
|
||||
await helper.set("global", { ...global, stateVersion: version });
|
||||
await helper.set("stateVersion", undefined);
|
||||
}
|
||||
|
||||
// Override is necessary because default implementation assumes `stateVersion` at the root, but this migration moves
|
||||
// it from a `global` object to root.This makes for unique rollback versioning.
|
||||
override async updateVersion(helper: MigrationHelper, direction: Direction): Promise<void> {
|
||||
const endVersion = direction === "up" ? this.toVersion : this.fromVersion;
|
||||
helper.currentVersion = endVersion;
|
||||
if (direction === "up") {
|
||||
await helper.set("stateVersion", endVersion);
|
||||
} else {
|
||||
const global: { stateVersion: number } = (await helper.get("global")) || ({} as any);
|
||||
await helper.set("global", { ...global, stateVersion: endVersion });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { MIN_VERSION } from "../migrate";
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { MinVersionMigrator } from "./min-version";
|
||||
|
||||
describe("MinVersionMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: MinVersionMigrator;
|
||||
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(null);
|
||||
sut = new MinVersionMigrator();
|
||||
});
|
||||
|
||||
describe("shouldMigrate", () => {
|
||||
it("should return true if current version is less than min version", async () => {
|
||||
helper.currentVersion = MIN_VERSION - 1;
|
||||
expect(await sut.shouldMigrate(helper)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if current version is greater than min version", async () => {
|
||||
helper.currentVersion = MIN_VERSION + 1;
|
||||
expect(await sut.shouldMigrate(helper)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
libs/common/src/state-migrations/migrations/min-version.ts
Normal file
26
libs/common/src/state-migrations/migrations/min-version.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { MinVersion, MIN_VERSION } from "../migrate";
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { IRREVERSIBLE, Migrator } from "../migrator";
|
||||
|
||||
export function minVersionError(current: number) {
|
||||
return `Your local data is too old to be migrated. Your current state version is ${current}, but minimum version is ${MIN_VERSION}.`;
|
||||
}
|
||||
|
||||
export class MinVersionMigrator extends Migrator<0, MinVersion> {
|
||||
constructor() {
|
||||
super(0, MIN_VERSION);
|
||||
}
|
||||
|
||||
// Overrides the default implementation to catch any version that may be passed in.
|
||||
override shouldMigrate(helper: MigrationHelper): Promise<boolean> {
|
||||
return Promise.resolve(helper.currentVersion < MIN_VERSION);
|
||||
}
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
if (helper.currentVersion < MIN_VERSION) {
|
||||
throw new Error(minVersionError(helper.currentVersion));
|
||||
}
|
||||
}
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
throw IRREVERSIBLE;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user