1
0
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:
Matt Gibson
2023-08-30 12:57:20 -05:00
committed by GitHub
parent b444eed0b5
commit 3340af8084
51 changed files with 1538 additions and 980 deletions

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}
}