Skip to content

Commit e066cae

Browse files
committed
test: add coverage for browser OAuth to REST session bridging
- IBClient.reauthenticate(): success, false status, network error - ensureAuth browser path: early return, throw, status flip, reauth failure - startBrowserAuthPolling: polls until auth, max attempts timeout, error resilience - authenticate browser mode: polling response, manual fallback, headless creds - getAlerts handler: basic integration test
1 parent df8d408 commit e066cae

2 files changed

Lines changed: 228 additions & 0 deletions

File tree

test/ib-client.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,51 @@ describe('IBClient', () => {
353353
});
354354
});
355355

356+
describe('reauthenticate', () => {
357+
it('should reauthenticate and start tickle on success', async () => {
358+
const mockAuthClient = {
359+
post: vi.fn().mockResolvedValue({ data: {} }),
360+
get: vi.fn().mockResolvedValue({
361+
data: { authenticated: true },
362+
}),
363+
};
364+
365+
vi.mocked(axios.create).mockReturnValueOnce(mockAuthClient as any);
366+
367+
await client.reauthenticate();
368+
369+
expect(mockAuthClient.post).toHaveBeenCalledWith('/iserver/reauthenticate');
370+
expect(mockAuthClient.get).toHaveBeenCalledWith('/iserver/auth/status');
371+
});
372+
373+
it('should handle reauth when status returns false', async () => {
374+
const mockAuthClient = {
375+
post: vi.fn().mockResolvedValue({ data: {} }),
376+
get: vi.fn().mockResolvedValue({
377+
data: { authenticated: false },
378+
}),
379+
};
380+
381+
vi.mocked(axios.create).mockReturnValueOnce(mockAuthClient as any);
382+
383+
// Should not throw — handled internally
384+
await expect(client.reauthenticate()).resolves.not.toThrow();
385+
expect(mockAuthClient.post).toHaveBeenCalledWith('/iserver/reauthenticate');
386+
});
387+
388+
it('should handle network errors gracefully', async () => {
389+
const mockAuthClient = {
390+
post: vi.fn().mockRejectedValue(new Error('Connection refused')),
391+
get: vi.fn(),
392+
};
393+
394+
vi.mocked(axios.create).mockReturnValueOnce(mockAuthClient as any);
395+
396+
// Should not throw — errors are caught internally
397+
await expect(client.reauthenticate()).resolves.not.toThrow();
398+
});
399+
});
400+
356401
describe('Cleanup', () => {
357402
it('should stop tickle on destroy', () => {
358403
// Start with authenticated state to trigger tickle

test/tool-handlers.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
33
import { ToolHandlers, ToolHandlerContext } from '../src/tool-handlers.js';
44
import { IBClient } from '../src/ib-client.js';
55
import { IBGatewayManager } from '../src/gateway-manager.js';
6+
import open from 'open';
67

78
// Mock dependencies
89
vi.mock('../src/ib-client.js');
@@ -22,6 +23,7 @@ describe('ToolHandlers', () => {
2223
// Create mock IBClient
2324
mockIBClient = {
2425
checkAuthenticationStatus: vi.fn().mockResolvedValue(true),
26+
reauthenticate: vi.fn().mockResolvedValue(undefined),
2527
getAccountInfo: vi.fn().mockResolvedValue({ accounts: [] }),
2628
getPositions: vi.fn().mockResolvedValue([]),
2729
getMarketData: vi.fn().mockResolvedValue({ price: 150 }),
@@ -31,6 +33,10 @@ describe('ToolHandlers', () => {
3133
confirmOrder: vi.fn().mockResolvedValue({ confirmed: true }),
3234
destroy: vi.fn(),
3335
updatePort: vi.fn(),
36+
getAlerts: vi.fn().mockResolvedValue([]),
37+
createAlert: vi.fn().mockResolvedValue({ request_id: '1' }),
38+
activateAlert: vi.fn().mockResolvedValue({ success: true }),
39+
deleteAlert: vi.fn().mockResolvedValue({ success: true }),
3440
} as any;
3541

3642
// Create mock GatewayManager
@@ -233,6 +239,74 @@ describe('ToolHandlers', () => {
233239
});
234240
});
235241

242+
describe('authenticate', () => {
243+
it('should open browser and return polling response in browser mode', async () => {
244+
context.config.IB_HEADLESS_MODE = false;
245+
vi.mocked(open).mockResolvedValueOnce(undefined as any);
246+
247+
const result = await handlers.authenticate({ confirm: true });
248+
249+
const response = JSON.parse(result.content[0].text);
250+
expect(response.mode).toBe('browser');
251+
expect(response.browserOpened).toBe(true);
252+
expect(response.polling).toBe(true);
253+
expect(response.authUrl).toContain('localhost:5000');
254+
expect(vi.mocked(open)).toHaveBeenCalledWith(response.authUrl);
255+
});
256+
257+
it('should return manual instructions when browser fails to open', async () => {
258+
context.config.IB_HEADLESS_MODE = false;
259+
vi.mocked(open).mockRejectedValueOnce(new Error('No browser available'));
260+
261+
const result = await handlers.authenticate({ confirm: true });
262+
263+
const response = JSON.parse(result.content[0].text);
264+
expect(response.mode).toBe('manual');
265+
expect(response.browserOpened).toBe(false);
266+
expect(response.instructions).toBeDefined();
267+
expect(response.instructions.length).toBeGreaterThan(0);
268+
});
269+
270+
it('should return full response with instructions in browser mode', async () => {
271+
context.config.IB_HEADLESS_MODE = false;
272+
vi.mocked(open).mockResolvedValueOnce(undefined as any);
273+
274+
const result = await handlers.authenticate({ confirm: true });
275+
276+
const response = JSON.parse(result.content[0].text);
277+
expect(response.mode).toBe('browser');
278+
expect(response.browserOpened).toBe(true);
279+
expect(response.polling).toBe(true);
280+
expect(response.message).toContain('authentication interface opened');
281+
expect(response.note).toContain('Polling for authentication completion');
282+
expect(response.instructions).toHaveLength(5);
283+
expect(response.instructions[0]).toContain('opened in your default browser');
284+
});
285+
286+
it('should return missing credentials error in headless mode', async () => {
287+
context.config.IB_HEADLESS_MODE = true;
288+
context.config.IB_USERNAME = '';
289+
context.config.IB_PASSWORD_AUTH = '';
290+
291+
const result = await handlers.authenticate({ confirm: true });
292+
293+
const response = JSON.parse(result.content[0].text);
294+
expect(response.success).toBe(false);
295+
expect(response.error).toContain('IB_USERNAME');
296+
});
297+
298+
it('should handle non-Error thrown by open', async () => {
299+
context.config.IB_HEADLESS_MODE = false;
300+
vi.mocked(open).mockRejectedValueOnce('spawn ENOENT');
301+
302+
const result = await handlers.authenticate({ confirm: true });
303+
304+
const response = JSON.parse(result.content[0].text);
305+
expect(response.mode).toBe('manual');
306+
expect(response.browserOpened).toBe(false);
307+
});
308+
});
309+
236310
describe('Headless Mode Authentication', () => {
237311
it('should trigger auth in headless mode', async () => {
238312
context.config.IB_HEADLESS_MODE = true;
@@ -251,6 +325,115 @@ describe('ToolHandlers', () => {
251325
});
252326
});
253327

328+
describe('ensureAuth — browser mode', () => {
329+
beforeEach(() => {
330+
context.config.IB_HEADLESS_MODE = false;
331+
});
332+
333+
it('should return early when already authenticated', async () => {
334+
mockIBClient.checkAuthenticationStatus = vi.fn().mockResolvedValue(true);
335+
mockIBClient.reauthenticate = vi.fn();
336+
337+
await (handlers as any).ensureAuth();
338+
339+
expect(mockIBClient.reauthenticate).not.toHaveBeenCalled();
340+
});
341+
342+
it('should throw when not authenticated on both checks', async () => {
343+
mockIBClient.checkAuthenticationStatus = vi.fn()
344+
.mockResolvedValueOnce(false)
345+
.mockResolvedValueOnce(false);
346+
mockIBClient.reauthenticate = vi.fn();
347+
348+
await expect((handlers as any).ensureAuth())
349+
.rejects.toThrow('Authentication required');
350+
expect(mockIBClient.reauthenticate).not.toHaveBeenCalled();
351+
});
352+
353+
it('should call reauthenticate when auth status flips to true', async () => {
354+
mockIBClient.checkAuthenticationStatus = vi.fn()
355+
.mockResolvedValueOnce(false) // Continue past early return
356+
.mockResolvedValueOnce(true); // Browser path: now authenticated
357+
mockIBClient.reauthenticate = vi.fn().mockResolvedValue(undefined);
358+
359+
await (handlers as any).ensureAuth();
360+
361+
expect(mockIBClient.reauthenticate).toHaveBeenCalled();
362+
});
363+
364+
it('should proceed even if reauthenticate fails', async () => {
365+
mockIBClient.checkAuthenticationStatus = vi.fn()
366+
.mockResolvedValueOnce(false)
367+
.mockResolvedValueOnce(true);
368+
mockIBClient.reauthenticate = vi.fn().mockRejectedValue(new Error('Reauth failed'));
369+
370+
// Should not throw — error is caught and logged
371+
await expect((handlers as any).ensureAuth()).resolves.not.toThrow();
372+
expect(mockIBClient.reauthenticate).toHaveBeenCalled();
373+
});
374+
});
375+
376+
describe('startBrowserAuthPolling', () => {
377+
beforeEach(() => {
378+
vi.useFakeTimers();
379+
});
380+
381+
afterEach(() => {
382+
vi.useRealTimers();
383+
});
384+
385+
it('should poll and call reauthenticate when auth detected', async () => {
386+
mockIBClient.checkAuthenticationStatus = vi.fn()
387+
.mockResolvedValueOnce(false)
388+
.mockResolvedValueOnce(true);
389+
mockIBClient.reauthenticate = vi.fn().mockResolvedValue(undefined);
390+
391+
(handlers as any).startBrowserAuthPolling('https://localhost:5000', 5000);
392+
await vi.runAllTimersAsync();
393+
394+
expect(mockIBClient.checkAuthenticationStatus).toHaveBeenCalledTimes(2);
395+
expect(mockIBClient.reauthenticate).toHaveBeenCalledTimes(1);
396+
});
397+
398+
it('should not call reauthenticate when auth never detected', async () => {
399+
mockIBClient.checkAuthenticationStatus = vi.fn().mockResolvedValue(false);
400+
mockIBClient.reauthenticate = vi.fn();
401+
402+
(handlers as any).startBrowserAuthPolling('https://localhost:5000', 5000);
403+
await vi.runAllTimersAsync();
404+
405+
expect(mockIBClient.checkAuthenticationStatus).toHaveBeenCalledTimes(60);
406+
expect(mockIBClient.reauthenticate).not.toHaveBeenCalled();
407+
});
408+
409+
it('should handle checkAuthenticationStatus throwing without stopping', async () => {
410+
mockIBClient.checkAuthenticationStatus = vi.fn()
411+
.mockRejectedValueOnce(new Error('Network error'))
412+
.mockResolvedValueOnce(true);
413+
mockIBClient.reauthenticate = vi.fn().mockResolvedValue(undefined);
414+
415+
(handlers as any).startBrowserAuthPolling('https://localhost:5000', 5000);
416+
await vi.runAllTimersAsync();
417+
418+
expect(mockIBClient.checkAuthenticationStatus).toHaveBeenCalledTimes(2);
419+
expect(mockIBClient.reauthenticate).toHaveBeenCalledTimes(1);
420+
});
421+
});
422+
423+
describe('getAlerts', () => {
424+
it('should return alerts for account', async () => {
425+
const mockAlerts = [{ alertId: '1', alertName: 'Price Alert' }];
426+
mockIBClient.getAlerts = vi.fn().mockResolvedValue(mockAlerts);
427+
428+
const result = await handlers.getAlerts({ accountId: 'U12345' });
429+
430+
expect(result.content).toBeDefined();
431+
expect(result.content[0].type).toBe('text');
432+
expect(mockGatewayManager.ensureGatewayReady).toHaveBeenCalled();
433+
expect(mockIBClient.getAlerts).toHaveBeenCalledWith('U12345');
434+
});
435+
});
436+
254437
describe('Error Handling', () => {
255438
it('should format authentication errors', async () => {
256439
const authError = new Error('Authentication required');

0 commit comments

Comments
 (0)