Skip to content

Commit b44f5ee

Browse files
authored
Merge pull request #1628 from fabiovincenzi/ssh-test-coverage
test(ssh): improve SSH proxy test coverage
2 parents d571a17 + e249ac5 commit b44f5ee

5 files changed

Lines changed: 1994 additions & 101 deletions

File tree

test/ssh/AgentForwarding.test.ts

Lines changed: 240 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
18-
import { LazySSHAgent, createLazyAgent } from '../../src/proxy/ssh/AgentForwarding';
17+
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
18+
import {
19+
LazySSHAgent,
20+
createLazyAgent,
21+
openTemporaryAgentChannel,
22+
} from '../../src/proxy/ssh/AgentForwarding';
1923
import { SSHAgentProxy } from '../../src/proxy/ssh/AgentProxy';
2024
import { ClientWithUser } from '../../src/proxy/ssh/types';
25+
import * as sshInternals from '../../src/proxy/ssh/sshInternals';
2126

2227
describe('AgentForwarding', () => {
2328
let mockClient: Partial<ClientWithUser>;
@@ -322,8 +327,6 @@ describe('AgentForwarding', () => {
322327

323328
describe('openTemporaryAgentChannel', () => {
324329
it('should return null when client has no protocol', async () => {
325-
const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding');
326-
327330
const clientWithoutProtocol: any = {
328331
agentForwardingEnabled: true,
329332
};
@@ -334,105 +337,267 @@ describe('AgentForwarding', () => {
334337
});
335338

336339
it('should handle timeout when channel confirmation not received', async () => {
337-
const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding');
340+
vi.useFakeTimers();
341+
try {
342+
const mockClient: any = {
343+
agentForwardingEnabled: true,
344+
_protocol: {
345+
_handlers: {},
346+
openssh_authAgent: vi.fn(),
347+
channelSuccess: vi.fn(),
348+
},
349+
_chanMgr: {
350+
_channels: {},
351+
_count: 0,
352+
},
353+
};
354+
355+
const promise = openTemporaryAgentChannel(mockClient);
356+
357+
// Advance past the 5s confirmation window instead of waiting in real time
358+
vi.advanceTimersByTime(5001);
359+
const result = await promise;
360+
361+
expect(result).toBeNull();
362+
} finally {
363+
vi.useRealTimers();
364+
}
365+
});
366+
367+
it('should find next available channel ID when channels exist', async () => {
368+
vi.useFakeTimers();
369+
try {
370+
const mockClient: any = {
371+
agentForwardingEnabled: true,
372+
_protocol: {
373+
_handlers: {},
374+
openssh_authAgent: vi.fn(),
375+
channelSuccess: vi.fn(),
376+
},
377+
_chanMgr: {
378+
_channels: {
379+
1: 'occupied',
380+
2: 'occupied',
381+
// Channel 3 should be used
382+
},
383+
_count: 2,
384+
},
385+
};
386+
387+
const promise = openTemporaryAgentChannel(mockClient);
388+
389+
// openssh_authAgent is called synchronously, before the promise settles
390+
expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith(
391+
3,
392+
expect.any(Number),
393+
expect.any(Number),
394+
);
395+
396+
// Advance past the confirmation timeout so the promise settles cleanly
397+
vi.advanceTimersByTime(5001);
398+
await promise;
399+
} finally {
400+
vi.useRealTimers();
401+
}
402+
});
403+
404+
it('should use channel ID 1 when no channels exist', async () => {
405+
vi.useFakeTimers();
406+
try {
407+
const mockClient: any = {
408+
agentForwardingEnabled: true,
409+
_protocol: {
410+
_handlers: {},
411+
openssh_authAgent: vi.fn(),
412+
channelSuccess: vi.fn(),
413+
},
414+
_chanMgr: {
415+
_channels: {},
416+
_count: 0,
417+
},
418+
};
338419

420+
const promise = openTemporaryAgentChannel(mockClient);
421+
422+
expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith(
423+
1,
424+
expect.any(Number),
425+
expect.any(Number),
426+
);
427+
428+
vi.advanceTimersByTime(5001);
429+
await promise;
430+
} finally {
431+
vi.useRealTimers();
432+
}
433+
});
434+
435+
it('should return null when client has no chanMgr', async () => {
339436
const mockClient: any = {
340437
agentForwardingEnabled: true,
341438
_protocol: {
342439
_handlers: {},
343440
openssh_authAgent: vi.fn(),
344441
channelSuccess: vi.fn(),
345442
},
346-
_chanMgr: {
347-
_channels: {},
348-
_count: 0,
349-
},
350443
};
351444

352445
const result = await openTemporaryAgentChannel(mockClient);
353446

354-
// Should timeout and return null after 5 seconds
355447
expect(result).toBeNull();
356-
}, 6000);
448+
expect(mockClient._protocol.openssh_authAgent).not.toHaveBeenCalled();
449+
});
357450

358-
it('should find next available channel ID when channels exist', async () => {
359-
const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding');
451+
describe('CHANNEL_OPEN_CONFIRMATION handler', () => {
452+
let getChannelModuleSpy: ReturnType<typeof vi.spyOn>;
360453

361-
const mockClient: any = {
362-
agentForwardingEnabled: true,
363-
_protocol: {
364-
_handlers: {},
365-
openssh_authAgent: vi.fn(),
366-
channelSuccess: vi.fn(),
367-
},
368-
_chanMgr: {
369-
_channels: {
370-
1: 'occupied',
371-
2: 'occupied',
372-
// Channel 3 should be used
454+
afterEach(() => {
455+
getChannelModuleSpy?.mockRestore();
456+
});
457+
458+
function makeMockClient(existingHandler?: (...args: unknown[]) => void) {
459+
const handlers: Record<string, (...args: unknown[]) => void> = {};
460+
if (existingHandler) {
461+
handlers.CHANNEL_OPEN_CONFIRMATION = existingHandler;
462+
}
463+
return {
464+
_protocol: {
465+
_handlers: handlers,
466+
openssh_authAgent: vi.fn(),
467+
channelSuccess: vi.fn(),
373468
},
374-
_count: 2,
375-
},
376-
};
469+
_chanMgr: { _channels: {} as Record<number, any>, _count: 0 },
470+
} as any;
471+
}
472+
473+
function mockChannelModule(channelImpl?: (...args: unknown[]) => unknown) {
474+
const mockChannel = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
475+
getChannelModuleSpy = vi.spyOn(sshInternals, 'getChannelModule').mockImplementation(() => ({
476+
Channel: channelImpl ?? vi.fn().mockImplementation(() => mockChannel),
477+
MAX_WINDOW: 2 * 1024 * 1024,
478+
PACKET_SIZE: 32 * 1024,
479+
}));
480+
return mockChannel;
481+
}
482+
483+
it('should create AgentProxy on successful confirmation', async () => {
484+
mockChannelModule();
485+
const client = makeMockClient();
486+
487+
const promise = openTemporaryAgentChannel(client);
488+
489+
const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION;
490+
handler(null, { recipient: 1, sender: 42, window: 65536, packetSize: 32768 });
491+
492+
const result = await promise;
493+
494+
expect(result).toBeInstanceOf(SSHAgentProxy);
495+
expect(client._chanMgr._channels[1]).toBeDefined();
496+
expect(client._chanMgr._count).toBe(1);
497+
});
377498

378-
// Start the operation but don't wait for completion (will timeout)
379-
const promise = openTemporaryAgentChannel(mockClient);
499+
it('should call and restore original handler on confirmation', async () => {
500+
mockChannelModule();
501+
const originalHandler = vi.fn();
502+
const client = makeMockClient(originalHandler);
380503

381-
// Verify openssh_authAgent was called with the next available channel (3)
382-
expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith(
383-
3,
384-
expect.any(Number),
385-
expect.any(Number),
386-
);
504+
const promise = openTemporaryAgentChannel(client);
387505

388-
// Clean up - wait for timeout
389-
await promise;
390-
}, 6000);
506+
const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION;
507+
const confirmInfo = { recipient: 1, sender: 42, window: 65536, packetSize: 32768 };
508+
handler(null, confirmInfo);
391509

392-
it('should use channel ID 1 when no channels exist', async () => {
393-
const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding');
510+
await promise;
394511

395-
const mockClient: any = {
396-
agentForwardingEnabled: true,
397-
_protocol: {
398-
_handlers: {},
399-
openssh_authAgent: vi.fn(),
400-
channelSuccess: vi.fn(),
401-
},
402-
_chanMgr: {
403-
_channels: {},
404-
_count: 0,
405-
},
406-
};
512+
expect(originalHandler).toHaveBeenCalledWith(null, confirmInfo);
513+
expect(client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION).toBe(originalHandler);
514+
});
407515

408-
const promise = openTemporaryAgentChannel(mockClient);
516+
it('should delete handler when no original existed', async () => {
517+
mockChannelModule();
518+
const client = makeMockClient();
409519

410-
expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith(
411-
1,
412-
expect.any(Number),
413-
expect.any(Number),
414-
);
520+
const promise = openTemporaryAgentChannel(client);
415521

416-
await promise;
417-
}, 6000);
522+
const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION;
523+
handler(null, { recipient: 1, sender: 42, window: 65536, packetSize: 32768 });
418524

419-
it('should return null when client has no chanMgr', async () => {
420-
const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding');
525+
await promise;
421526

422-
const mockClient: any = {
423-
agentForwardingEnabled: true,
424-
_protocol: {
425-
_handlers: {},
426-
openssh_authAgent: vi.fn(),
427-
channelSuccess: vi.fn(),
428-
},
429-
// No _chanMgr — the internals guard should reject and return null
430-
};
527+
expect(client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION).toBeUndefined();
528+
});
431529

432-
const result = await openTemporaryAgentChannel(mockClient);
530+
it('should ignore confirmation for non-matching channel recipient', async () => {
531+
vi.useFakeTimers();
532+
try {
533+
mockChannelModule();
534+
const client = makeMockClient();
433535

434-
expect(result).toBeNull();
435-
expect(mockClient._protocol.openssh_authAgent).not.toHaveBeenCalled();
536+
const promise = openTemporaryAgentChannel(client);
537+
538+
const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION;
539+
handler(null, { recipient: 999, sender: 42, window: 65536, packetSize: 32768 });
540+
541+
vi.advanceTimersByTime(5001);
542+
543+
const result = await promise;
544+
expect(result).toBeNull();
545+
} finally {
546+
vi.useRealTimers();
547+
}
548+
});
549+
550+
it('should resolve null when Channel constructor throws', async () => {
551+
mockChannelModule(function () {
552+
throw new Error('Channel creation failed');
553+
});
554+
const client = makeMockClient();
555+
556+
const promise = openTemporaryAgentChannel(client);
557+
558+
const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION;
559+
handler(null, { recipient: 1, sender: 42, window: 65536, packetSize: 32768 });
560+
561+
const result = await promise;
562+
expect(result).toBeNull();
563+
});
564+
});
565+
});
566+
567+
describe('LazySSHAgent - lock recovery', () => {
568+
it('should continue operating after a previous operation fails', () => {
569+
return new Promise<void>((resolve) => {
570+
const openChannelFn = vi.fn();
571+
const client = {
572+
agentForwardingEnabled: true,
573+
clientIp: '127.0.0.1',
574+
authenticatedUser: { username: 'testuser' },
575+
} as unknown as ClientWithUser;
576+
577+
openChannelFn.mockRejectedValueOnce(new Error('Channel open failed'));
578+
579+
const identities = [
580+
{ publicKeyBlob: Buffer.from('key1'), comment: 'k', algorithm: 'ssh-ed25519' },
581+
];
582+
const mockProxy = {
583+
getIdentities: vi.fn().mockResolvedValue(identities),
584+
sign: vi.fn(),
585+
close: vi.fn(),
586+
};
587+
openChannelFn.mockResolvedValueOnce(mockProxy);
588+
589+
const agent = new LazySSHAgent(openChannelFn, client);
590+
591+
agent.getIdentities((err) => {
592+
expect(err).toBeDefined();
593+
594+
agent.getIdentities((err2, keys) => {
595+
expect(err2).toBeNull();
596+
expect(keys).toHaveLength(1);
597+
resolve();
598+
});
599+
});
600+
});
436601
});
437602
});
438603
});

0 commit comments

Comments
 (0)