- Add SecureKeyStore class using safeStorage encrypt/decrypt with base64 in SQLite - Update chatHandlers to store/retrieve API keys via SecureKeyStore - Delete old plain-text opencode_api_key on startup (no migration, re-enter key) - Add deleteSetting() to ChatEngine - Add 14 SecureKeyStore unit tests and 6 chatHandlers keychain integration tests - Update existing chatHandlers test mocks for SecureKeyStore - Update MISTRAL_PLAN.md: mark PR 1 done, remove legacy fallback from PR 2 scope
178 lines
5.5 KiB
TypeScript
178 lines
5.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const registeredHandlers = new Map<string, (...args: any[]) => Promise<any>>();
|
|
|
|
const webContentsSend = vi.fn();
|
|
const mainWindowMock = {
|
|
webContents: {
|
|
send: webContentsSend,
|
|
},
|
|
};
|
|
|
|
const chatEngineInstances: Array<Record<string, any>> = [];
|
|
const openCodeManagerInstances: Array<Record<string, any>> = [];
|
|
const secureKeyStoreInstances: Array<Record<string, any>> = [];
|
|
|
|
vi.mock('electron', () => ({
|
|
BrowserWindow: {
|
|
fromWebContents: vi.fn(),
|
|
},
|
|
ipcMain: {
|
|
handle: vi.fn((channel: string, handler: (...args: any[]) => Promise<any>) => {
|
|
registeredHandlers.set(channel, handler);
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/main/database', () => ({
|
|
getDatabase: vi.fn(() => ({})),
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/PostEngine', () => ({
|
|
getPostEngine: vi.fn(() => ({})),
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/MediaEngine', () => ({
|
|
getMediaEngine: vi.fn(() => ({})),
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/ChatEngine', () => ({
|
|
ChatEngine: class {
|
|
constructor() {
|
|
const instance = {
|
|
getSetting: vi.fn(async () => null),
|
|
setSetting: vi.fn(async () => undefined),
|
|
deleteSetting: vi.fn(async () => undefined),
|
|
getSelectedModel: vi.fn(async () => 'gpt-5'),
|
|
getDefaultSystemPrompt: vi.fn(async () => 'system prompt'),
|
|
};
|
|
chatEngineInstances.push(instance);
|
|
return instance;
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/OpenCodeManager', () => ({
|
|
OpenCodeManager: class {
|
|
constructor() {
|
|
const instance = {
|
|
setApiKey: vi.fn(),
|
|
checkReady: vi.fn(async () => ({ ready: true })),
|
|
validateApiKey: vi.fn(async () => ({ isValid: true, models: [] })),
|
|
getApiKey: vi.fn(() => 'abc12345'),
|
|
getAvailableModels: vi.fn(async () => []),
|
|
sendMessage: vi.fn(async (_conversationId: string, _message: string, options: any) => {
|
|
options?.onDelta?.('stream-delta');
|
|
options?.onToolCall?.({ name: 'search_posts', args: { query: 'q' } });
|
|
options?.onToolResult?.({ name: 'search_posts', result: { ok: true } });
|
|
options?.onTokenUsage?.({
|
|
inputTokens: 100, outputTokens: 50,
|
|
cacheReadTokens: 80, cacheWriteTokens: 20, totalTokens: 250,
|
|
cumulativeInputTokens: 100, cumulativeOutputTokens: 50,
|
|
cumulativeCacheReadTokens: 80, cumulativeCacheWriteTokens: 20,
|
|
cumulativeTotalTokens: 250,
|
|
});
|
|
return {
|
|
success: true,
|
|
message: 'assistant reply',
|
|
};
|
|
}),
|
|
abortMessage: vi.fn(async () => ({ success: true })),
|
|
analyzeTaxonomy: vi.fn(async () => ({ success: true })),
|
|
analyzeMediaImage: vi.fn(async () => ({ success: true })),
|
|
stop: vi.fn(async () => undefined),
|
|
};
|
|
openCodeManagerInstances.push(instance);
|
|
return instance;
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/main/engine/SecureKeyStore', () => ({
|
|
SecureKeyStore: class {
|
|
constructor() {
|
|
const instance = {
|
|
isAvailable: vi.fn(() => true),
|
|
store: vi.fn(async () => undefined),
|
|
retrieve: vi.fn(async () => 'stored-key'),
|
|
remove: vi.fn(async () => undefined),
|
|
cleanupPlainTextKey: vi.fn(async () => undefined),
|
|
};
|
|
secureKeyStoreInstances.push(instance);
|
|
return instance;
|
|
}
|
|
},
|
|
}));
|
|
|
|
describe('chatHandlers', () => {
|
|
beforeEach(() => {
|
|
registeredHandlers.clear();
|
|
webContentsSend.mockReset();
|
|
chatEngineInstances.length = 0;
|
|
openCodeManagerInstances.length = 0;
|
|
secureKeyStoreInstances.length = 0;
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const mod = await import('../../src/main/ipc/chatHandlers');
|
|
await mod.cleanupChatHandlers();
|
|
});
|
|
|
|
it('streams sendMessage callbacks through main window events', async () => {
|
|
const mod = await import('../../src/main/ipc/chatHandlers');
|
|
const mockBundle = {
|
|
postEngine: {},
|
|
mediaEngine: {},
|
|
postMediaEngine: {},
|
|
};
|
|
mod.initializeChatHandlers(() => mainWindowMock as never, mockBundle as any);
|
|
mod.registerChatHandlers();
|
|
|
|
const handler = registeredHandlers.get('chat:sendMessage');
|
|
expect(handler).toBeDefined();
|
|
|
|
const result = await handler!(
|
|
undefined,
|
|
'conversation-1',
|
|
'hello assistant',
|
|
{ surface: 'sidebar' },
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
const manager = openCodeManagerInstances[0];
|
|
expect(manager.setApiKey).toHaveBeenCalledWith('stored-key');
|
|
expect(manager.sendMessage).toHaveBeenCalledWith(
|
|
'conversation-1',
|
|
'hello assistant',
|
|
expect.objectContaining({
|
|
metadata: { surface: 'sidebar' },
|
|
onDelta: expect.any(Function),
|
|
onToolCall: expect.any(Function),
|
|
onToolResult: expect.any(Function),
|
|
onTokenUsage: expect.any(Function),
|
|
}),
|
|
);
|
|
|
|
expect(webContentsSend).toHaveBeenCalledWith('chat-stream-delta', {
|
|
conversationId: 'conversation-1',
|
|
delta: 'stream-delta',
|
|
});
|
|
expect(webContentsSend).toHaveBeenCalledWith('chat-tool-call', {
|
|
conversationId: 'conversation-1',
|
|
toolCall: { name: 'search_posts', args: { query: 'q' } },
|
|
});
|
|
expect(webContentsSend).toHaveBeenCalledWith('chat-tool-result', {
|
|
conversationId: 'conversation-1',
|
|
result: { name: 'search_posts', result: { ok: true } },
|
|
});
|
|
expect(webContentsSend).toHaveBeenCalledWith('chat-token-usage', expect.objectContaining({
|
|
conversationId: 'conversation-1',
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheReadTokens: 80,
|
|
}));
|
|
});
|
|
});
|