fix: rollback in-memory key on store failure, add corrupted-data and rollback tests
This commit is contained in:
@@ -127,10 +127,16 @@ export function registerChatHandlers(): void {
|
|||||||
ipcMain.handle('chat:setApiKey', async (_, apiKey: string) => {
|
ipcMain.handle('chat:setApiKey', async (_, apiKey: string) => {
|
||||||
try {
|
try {
|
||||||
const manager = await getOpenCodeManager();
|
const manager = await getOpenCodeManager();
|
||||||
|
const previousKey = manager.getApiKey();
|
||||||
manager.setApiKey(apiKey);
|
manager.setApiKey(apiKey);
|
||||||
|
|
||||||
// Persist to encrypted storage
|
// Persist to encrypted storage — roll back in-memory key on failure
|
||||||
|
try {
|
||||||
await getSecureKeyStore().store('opencode_api_key', apiKey);
|
await getSecureKeyStore().store('opencode_api_key', apiKey);
|
||||||
|
} catch (storeError) {
|
||||||
|
manager.setApiKey(previousKey);
|
||||||
|
throw storeError;
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -778,19 +778,34 @@ describe('ChatEngine', () => {
|
|||||||
|
|
||||||
describe('deleteSetting', () => {
|
describe('deleteSetting', () => {
|
||||||
it('should delete a setting by key', async () => {
|
it('should delete a setting by key', async () => {
|
||||||
|
let capturedTable: any;
|
||||||
let capturedPredicate: any;
|
let capturedPredicate: any;
|
||||||
vi.mocked(mockLocalDb.delete).mockImplementation(() => ({
|
vi.mocked(mockLocalDb.delete).mockImplementation((table: any) => {
|
||||||
|
capturedTable = table;
|
||||||
|
return {
|
||||||
where: vi.fn((predicate) => {
|
where: vi.fn((predicate) => {
|
||||||
capturedPredicate = predicate;
|
capturedPredicate = predicate;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}),
|
}),
|
||||||
} as any));
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
await chatEngine.deleteSetting('opencode_api_key');
|
await chatEngine.deleteSetting('opencode_api_key');
|
||||||
|
|
||||||
expect(mockLocalDb.delete).toHaveBeenCalled();
|
expect(mockLocalDb.delete).toHaveBeenCalledTimes(1);
|
||||||
|
// Verify the correct table was targeted
|
||||||
|
expect(capturedTable).toBeDefined();
|
||||||
|
// Verify a where predicate was passed
|
||||||
expect(capturedPredicate).toBeDefined();
|
expect(capturedPredicate).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not throw for nonexistent keys', async () => {
|
||||||
|
vi.mocked(mockLocalDb.delete).mockImplementation(() => ({
|
||||||
|
where: vi.fn(() => Promise.resolve()),
|
||||||
|
} as any));
|
||||||
|
|
||||||
|
await expect(chatEngine.deleteSetting('nonexistent_key')).resolves.not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setSelectedModel', () => {
|
describe('setSelectedModel', () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* and ChatEngine dependencies.
|
* and ChatEngine dependencies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
// Track mock state
|
// Track mock state
|
||||||
let safeStorageAvailable = true;
|
let safeStorageAvailable = true;
|
||||||
@@ -205,4 +205,24 @@ describe('SecureKeyStore', () => {
|
|||||||
expect(mockChatEngine.deleteSetting).toHaveBeenCalledWith('opencode_api_key');
|
expect(mockChatEngine.deleteSetting).toHaveBeenCalledWith('opencode_api_key');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('retrieve with corrupted data', () => {
|
||||||
|
it('throws when stored base64 decodes to invalid ciphertext', async () => {
|
||||||
|
const store = new SecureKeyStore(mockChatEngine as any);
|
||||||
|
|
||||||
|
// Simulate corrupted data: valid base64 but not a valid encrypted buffer
|
||||||
|
mockSettings.set('__encrypted_api_key', Buffer.from('CORRUPT:garbage').toString('base64'));
|
||||||
|
|
||||||
|
await expect(store.retrieve('api_key')).rejects.toThrow('Failed to decrypt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when stored value is not valid base64', async () => {
|
||||||
|
const store = new SecureKeyStore(mockChatEngine as any);
|
||||||
|
|
||||||
|
// Not valid base64 — Buffer.from tolerates this but decryptString will reject it
|
||||||
|
mockSettings.set('__encrypted_api_key', '!!!not-base64!!!');
|
||||||
|
|
||||||
|
await expect(store.retrieve('api_key')).rejects.toThrow('Failed to decrypt');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ describe('chatHandlers keychain integration', () => {
|
|||||||
expect(manager.setApiKey).toHaveBeenCalledWith('encrypted-stored-key');
|
expect(manager.setApiKey).toHaveBeenCalledWith('encrypted-stored-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error when store() throws on chat:setApiKey', async () => {
|
it('returns error and rolls back in-memory key when store() throws on chat:setApiKey', async () => {
|
||||||
secureKeyStoreStoreError = new Error('encryption unavailable');
|
secureKeyStoreStoreError = new Error('encryption unavailable');
|
||||||
|
|
||||||
const mod = await import('../../src/main/ipc/chatHandlers');
|
const mod = await import('../../src/main/ipc/chatHandlers');
|
||||||
@@ -266,10 +266,28 @@ describe('chatHandlers keychain integration', () => {
|
|||||||
mod.initializeChatHandlers(() => mainWindowMock as never, mockBundle as any);
|
mod.initializeChatHandlers(() => mainWindowMock as never, mockBundle as any);
|
||||||
mod.registerChatHandlers();
|
mod.registerChatHandlers();
|
||||||
|
|
||||||
|
// Trigger init to load the existing key
|
||||||
|
const checkHandler = registeredHandlers.get('chat:checkReady');
|
||||||
|
await checkHandler!(undefined);
|
||||||
|
|
||||||
|
const manager = openCodeManagerInstances[0];
|
||||||
|
// After init, the manager has the key from SecureKeyStore
|
||||||
|
expect(manager.setApiKey).toHaveBeenCalledWith('encrypted-stored-key');
|
||||||
|
manager.setApiKey.mockClear();
|
||||||
|
|
||||||
|
// getApiKey returns the current in-memory key (to be restored on rollback)
|
||||||
|
manager.getApiKey.mockReturnValue('encrypted-stored-key');
|
||||||
|
|
||||||
const handler = registeredHandlers.get('chat:setApiKey');
|
const handler = registeredHandlers.get('chat:setApiKey');
|
||||||
const result = await handler!(undefined, 'sk-new-key');
|
const result = await handler!(undefined, 'sk-new-key');
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain('encryption unavailable');
|
expect(result.error).toContain('encryption unavailable');
|
||||||
|
|
||||||
|
// setApiKey should have been called twice:
|
||||||
|
// 1) with the new key (optimistic), 2) with the old key (rollback)
|
||||||
|
expect(manager.setApiKey).toHaveBeenCalledTimes(2);
|
||||||
|
expect(manager.setApiKey).toHaveBeenNthCalledWith(1, 'sk-new-key');
|
||||||
|
expect(manager.setApiKey).toHaveBeenNthCalledWith(2, 'encrypted-stored-key');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user