1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-04 18:53:20 +00:00

Added error recovery to constructor.

This commit is contained in:
Todd Martin
2025-11-29 11:29:05 -05:00
parent e8878552a5
commit ea89074f34
2 changed files with 217 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
import * as fs from "fs";
import { ipcMain } from "electron";
import ElectronStore from "electron-store";
import { mock, MockProxy } from "jest-mock-extended";
import { LogService } from "@bitwarden/logging";
@@ -37,6 +38,9 @@ jest.mock("electron-store", () => {
};
});
// Get reference to the mocked constructor after the mock is set up
const mockStoreConstructor = ElectronStore as unknown as jest.Mock;
describe("ElectronStorageService", () => {
let service: ElectronStorageService;
let logService: MockProxy<LogService>;
@@ -51,6 +55,10 @@ describe("ElectronStorageService", () => {
mockStoreInstance.delete.mockReset();
mockStoreInstance.has.mockReset();
// Reset the constructor mock to return the store instance successfully
mockStoreConstructor.mockClear();
mockStoreConstructor.mockImplementation(() => mockStoreInstance);
logService = mock<LogService>();
(fs.existsSync as jest.Mock).mockReturnValue(true);
@@ -77,6 +85,180 @@ describe("ElectronStorageService", () => {
});
});
describe("Constructor Error Recovery", () => {
beforeEach(() => {
jest.clearAllMocks();
mockStoreConstructor.mockClear();
// Reset fs methods to their default mocks
(fs.existsSync as jest.Mock).mockReturnValue(true);
(fs.renameSync as jest.Mock) = jest.fn();
(fs.unlinkSync as jest.Mock) = jest.fn();
logService = mock<LogService>();
});
it("should initialize successfully when ElectronStore does not throw", () => {
mockStoreConstructor.mockImplementation(() => mockStoreInstance);
expect(() => new ElectronStorageService(logService, testDir)).not.toThrow();
expect(logService.error).not.toHaveBeenCalled();
});
it("should backup corrupted file and retry when initialization fails", () => {
// Mock the constructor to throw once, then succeed
mockStoreConstructor
.mockImplementationOnce(() => {
throw new SyntaxError("Unexpected token in JSON at position 22");
})
.mockImplementationOnce(() => mockStoreInstance);
const dataFilePath = `${testDir}/data.json`;
(fs.existsSync as jest.Mock).mockImplementation((path: string) => {
return path === testDir || path === dataFilePath;
});
const testService = new ElectronStorageService(logService, testDir);
expect(testService).toBeDefined();
expect(logService.error).toHaveBeenCalledWith(
"ElectronStore initialization failed, attempting recovery",
expect.any(SyntaxError),
);
expect(fs.renameSync).toHaveBeenCalledWith(
dataFilePath,
expect.stringMatching(/data\.json\.corrupt\.\d+/),
);
expect(logService.warning).toHaveBeenCalledWith(
expect.stringMatching(/Backed up corrupted data file to/),
);
expect(logService.info).toHaveBeenCalledWith("ElectronStore recovered successfully");
expect(mockStoreConstructor).toHaveBeenCalledTimes(2);
});
it("should delete file if backup fails", () => {
mockStoreConstructor
.mockImplementationOnce(() => {
throw new SyntaxError("Unexpected token in JSON");
})
.mockImplementationOnce(() => mockStoreInstance);
const dataFilePath = `${testDir}/data.json`;
(fs.existsSync as jest.Mock).mockImplementation((path: string) => {
return path === testDir || path === dataFilePath;
});
(fs.renameSync as jest.Mock).mockImplementation(() => {
throw new Error("Permission denied");
});
const testService = new ElectronStorageService(logService, testDir);
expect(testService).toBeDefined();
expect(logService.error).toHaveBeenCalledWith(
"Failed to backup corrupted file",
expect.any(Error),
);
expect(fs.unlinkSync).toHaveBeenCalledWith(dataFilePath);
expect(logService.warning).toHaveBeenCalledWith("Deleted corrupted data file");
expect(logService.info).toHaveBeenCalledWith("ElectronStore recovered successfully");
});
it("should log error if both backup and delete fail", () => {
mockStoreConstructor
.mockImplementationOnce(() => {
throw new SyntaxError("Unexpected token in JSON");
})
.mockImplementationOnce(() => mockStoreInstance);
const dataFilePath = `${testDir}/data.json`;
(fs.existsSync as jest.Mock).mockImplementation((path: string) => {
return path === testDir || path === dataFilePath;
});
(fs.renameSync as jest.Mock).mockImplementation(() => {
throw new Error("Permission denied");
});
(fs.unlinkSync as jest.Mock).mockImplementation(() => {
throw new Error("Permission denied");
});
const testService = new ElectronStorageService(logService, testDir);
expect(testService).toBeDefined();
expect(logService.error).toHaveBeenCalledWith(
"Failed to backup corrupted file",
expect.any(Error),
);
expect(logService.error).toHaveBeenCalledWith(
"Failed to delete corrupted file",
expect.any(Error),
);
// Should still recover if retry succeeds
expect(logService.info).toHaveBeenCalledWith("ElectronStore recovered successfully");
});
it("should not attempt file operations if data file does not exist", () => {
mockStoreConstructor
.mockImplementationOnce(() => {
throw new SyntaxError("Unexpected token in JSON");
})
.mockImplementationOnce(() => mockStoreInstance);
(fs.existsSync as jest.Mock).mockImplementation((path: string) => {
return path === testDir; // Only dir exists, not the data file
});
const testService = new ElectronStorageService(logService, testDir);
expect(testService).toBeDefined();
expect(fs.renameSync).not.toHaveBeenCalled();
expect(fs.unlinkSync).not.toHaveBeenCalled();
expect(logService.info).toHaveBeenCalledWith("ElectronStore recovered successfully");
});
it("should throw error if recovery fails", () => {
const recoveryError = new Error("Failed to create store after cleanup");
mockStoreConstructor.mockImplementation(() => {
throw recoveryError;
});
const dataFilePath = `${testDir}/data.json`;
(fs.existsSync as jest.Mock).mockImplementation((path: string) => {
return path === testDir || path === dataFilePath;
});
expect(() => new ElectronStorageService(logService, testDir)).toThrow(recoveryError);
expect(logService.error).toHaveBeenCalledWith(
"Failed to recover ElectronStore",
recoveryError,
);
});
it("should use timestamp in backup filename", () => {
const mockTimestamp = 1234567890123;
const originalDateNow = Date.now;
Date.now = jest.fn(() => mockTimestamp);
mockStoreConstructor
.mockImplementationOnce(() => {
throw new SyntaxError("Unexpected token in JSON");
})
.mockImplementationOnce(() => mockStoreInstance);
const dataFilePath = `${testDir}/data.json`;
(fs.existsSync as jest.Mock).mockImplementation((path: string) => {
return path === testDir || path === dataFilePath;
});
new ElectronStorageService(logService, testDir);
expect(fs.renameSync).toHaveBeenCalledWith(
dataFilePath,
`${testDir}/data.json.corrupt.${mockTimestamp}`,
);
Date.now = originalDateNow;
});
});
describe("valuesRequireDeserialization", () => {
it("should return true", () => {
expect(service.valuesRequireDeserialization).toBe(true);

View File

@@ -46,7 +46,41 @@ export class ElectronStorageService implements AbstractStorageService {
configFileMode: fileMode,
};
this.store = new ElectronStore(storeConfig);
try {
this.store = new ElectronStore(storeConfig);
} catch (error) {
// If initialization fails due to corrupted JSON, backup and recreate
this.logService.error("ElectronStore initialization failed, attempting recovery", error);
const dataFilePath = `${dir}/data.json`;
if (fs.existsSync(dataFilePath)) {
try {
// Backup the corrupted file
const backupPath = `${dir}/data.json.corrupt.${Date.now()}`;
fs.renameSync(dataFilePath, backupPath);
this.logService.warning(`Backed up corrupted data file to ${backupPath}`);
} catch (backupError) {
this.logService.error("Failed to backup corrupted file", backupError);
// If backup fails, try to delete the corrupted file
try {
fs.unlinkSync(dataFilePath);
this.logService.warning("Deleted corrupted data file");
} catch (deleteError) {
this.logService.error("Failed to delete corrupted file", deleteError);
}
}
}
// Try to create the store again with a clean slate
try {
this.store = new ElectronStore(storeConfig);
this.logService.info("ElectronStore recovered successfully");
} catch (retryError) {
this.logService.error("Failed to recover ElectronStore", retryError);
throw retryError;
}
}
this.updates$ = this.updatesSubject.asObservable();
ipcMain.handle("storageService", (event, options: Options) => {