From c71395a0ba11f9f6b02ef427581d0adbc5f8dee5 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 1 Jul 2026 12:16:33 +0200 Subject: [PATCH 1/5] test(ssh): add GitProtocol tests --- test/ssh/GitProtocol.test.ts | 847 ++++++++++++++++++++++++++++++++++- 1 file changed, 846 insertions(+), 1 deletion(-) diff --git a/test/ssh/GitProtocol.test.ts b/test/ssh/GitProtocol.test.ts index 3dbba87f4..b004c58b6 100644 --- a/test/ssh/GitProtocol.test.ts +++ b/test/ssh/GitProtocol.test.ts @@ -36,8 +36,18 @@ vi.mock('../../src/proxy/ssh/sshHelpers', () => ({ })), })); +// Mock config for isDebugEnabled() +vi.mock('../../src/config', () => ({ + getSSHConfig: vi.fn(() => ({ debug: false })), +})); + // Import after mocking -import { fetchGitHubCapabilities, fetchRepositoryData } from '../../src/proxy/ssh/GitProtocol'; +import { + fetchGitHubCapabilities, + fetchRepositoryData, + forwardPackDataToRemote, + connectToRemoteGitServer, +} from '../../src/proxy/ssh/GitProtocol'; import { ClientWithUser } from '../../src/proxy/ssh/types'; describe('GitProtocol', () => { @@ -237,6 +247,56 @@ describe('GitProtocol', () => { expect(errorMessage).toContain('https://github.com/settings/keys'); }); + it('should provide generic help for authentication failures on other hosts', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const mockStream = { + stderr: { + write: vi.fn(), + }, + exit: vi.fn(), + end: vi.fn(), + }; + + Client.mockImplementation(() => { + const mockClient = { + on: vi.fn((event, handler) => { + if (event === 'error') { + setImmediate(() => { + handler(new Error('All configured authentication methods failed')); + }); + } + return mockClient; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn(), + }; + return mockClient; + }); + + const { forwardPackDataToRemote } = await import('../../src/proxy/ssh/GitProtocol'); + + try { + await forwardPackDataToRemote( + 'git-receive-pack /test/repo.git', + mockStream as any, + mockClient as ClientWithUser, + Buffer.from('test'), + 0, + 'bitbucket.org', + ); + } catch (e) { + // Expected to fail + } + + expect(mockStream.stderr.write).toHaveBeenCalled(); + const errorMessage = mockStream.stderr.write.mock.calls[0][0]; + expect(errorMessage).toContain('SSH Authentication Failed'); + expect(errorMessage).toContain('Check your Git hosting provider'); + }); + it('should provide GitLab-specific help for authentication failures on gitlab.com', async () => { const ssh2 = await import('ssh2'); const Client = ssh2.Client as any; @@ -288,4 +348,789 @@ describe('GitProtocol', () => { expect(errorMessage).toContain('https://gitlab.com/-/profile/keys'); }); }); + + describe('fetchGitHubCapabilities - happy path', () => { + function createMockRemoteStream() { + const handlers: Record void)[]> = {}; + return { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!handlers[event]) handlers[event] = []; + handlers[event].push(handler); + }), + end: vi.fn(), + write: vi.fn(), + emit(event: string, ...args: any[]) { + (handlers[event] || []).forEach((h) => h(...args)); + }, + }; + } + + it('should collect data and return buffer when flush packet is received', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + // Build a valid pkt-line payload with flush packet + // "0012hello world\n0000" + const line = 'hello world\n'; + const pktLen = (4 + line.length).toString(16).padStart(4, '0'); + const pktData = Buffer.from(`${pktLen}${line}0000`); + + const mockRemoteStream = createMockRemoteStream(); + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + // Simulate data arrival after stream is set up + setImmediate(() => { + mockRemoteStream.emit('data', pktData); + // After flush is detected, the code calls remoteStream.end() + // then we fire close + setImmediate(() => mockRemoteStream.emit('close')); + }); + }), + }; + return c; + }); + + const result = await fetchGitHubCapabilities( + 'git-upload-pack /test/repo.git', + mockClient as ClientWithUser, + 'github.com', + ); + + expect(result).toBeInstanceOf(Buffer); + expect(result.toString()).toContain('hello world'); + expect(mockRemoteStream.end).toHaveBeenCalled(); + }); + + it('should accumulate multiple data chunks before flush', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const chunk1 = Buffer.from('0010chunk one\n'); + const chunk2 = Buffer.from('0010chunk two\n0000'); + + const mockRemoteStream = createMockRemoteStream(); + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + setImmediate(() => { + mockRemoteStream.emit('data', chunk1); + setImmediate(() => { + mockRemoteStream.emit('data', chunk2); + setImmediate(() => mockRemoteStream.emit('close')); + }); + }); + }), + }; + return c; + }); + + const result = await fetchGitHubCapabilities( + 'git-upload-pack /test/repo.git', + mockClient as ClientWithUser, + 'github.com', + ); + + expect(result.toString()).toContain('chunk one'); + expect(result.toString()).toContain('chunk two'); + }); + }); + + describe('fetchRepositoryData - happy path', () => { + it('should send request and collect response data', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const responseChunk1 = Buffer.from('response-part-1'); + const responseChunk2 = Buffer.from('response-part-2'); + + const handlers: Record void)[]> = {}; + const mockRemoteStream = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!handlers[event]) handlers[event] = []; + handlers[event].push(handler); + }), + end: vi.fn(), + write: vi.fn(), + }; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + setImmediate(() => { + (handlers['data'] || []).forEach((h) => h(responseChunk1)); + (handlers['data'] || []).forEach((h) => h(responseChunk2)); + setImmediate(() => (handlers['close'] || []).forEach((h) => h())); + }); + }), + }; + return c; + }); + + const result = await fetchRepositoryData( + 'git-upload-pack /test/repo.git', + mockClient as ClientWithUser, + 'github.com', + '0009want abc\n0000', + ); + + expect(mockRemoteStream.write).toHaveBeenCalledWith('0009want abc\n0000'); + expect(result.toString()).toBe('response-part-1response-part-2'); + }); + }); + + describe('forwardPackDataToRemote - happy path', () => { + it('should forward pack data and skip capabilities in response', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const handlers: Record void)[]> = {}; + const mockRemoteStream = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!handlers[event]) handlers[event] = []; + handlers[event].push(handler); + }), + end: vi.fn(), + write: vi.fn(), + }; + + const mockClientStream = { + stderr: { write: vi.fn() }, + exit: vi.fn(), + end: vi.fn(), + write: vi.fn(), + }; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + setImmediate(() => { + // Simulate response: first 10 bytes are capabilities (to skip), rest is actual data + const fullResponse = Buffer.from('CAPABILITYactual-response-data'); + (handlers['data'] || []).forEach((h) => h(fullResponse)); + setImmediate(() => (handlers['close'] || []).forEach((h) => h())); + }); + }), + }; + return c; + }); + + const packData = Buffer.from('pack-data-content'); + const capabilitiesSize = 10; // "CAPABILITY" is 10 bytes + + await forwardPackDataToRemote( + 'git-receive-pack /test/repo.git', + mockClientStream as any, + mockClient as ClientWithUser, + packData, + capabilitiesSize, + 'github.com', + ); + + // Pack data should be written to remote + expect(mockRemoteStream.write).toHaveBeenCalledWith(packData); + expect(mockRemoteStream.end).toHaveBeenCalled(); + // After skipping 10 bytes of capabilities, the remaining data should be forwarded + expect(mockClientStream.write).toHaveBeenCalledWith(Buffer.from('actual-response-data')); + }); + + it('should skip capabilities split across multiple chunks', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const handlers: Record void)[]> = {}; + const mockRemoteStream = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!handlers[event]) handlers[event] = []; + handlers[event].push(handler); + }), + end: vi.fn(), + write: vi.fn(), + }; + + const mockClientStream = { + stderr: { write: vi.fn() }, + exit: vi.fn(), + end: vi.fn(), + write: vi.fn(), + }; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + setImmediate(() => { + // First chunk is entirely within capabilities (5 bytes < 10 cap size) + (handlers['data'] || []).forEach((h) => h(Buffer.from('ABCDE'))); + // Second chunk: 5 more bytes of cap + actual data + (handlers['data'] || []).forEach((h) => h(Buffer.from('FGHIJreal-data'))); + // Third chunk: all real data + (handlers['data'] || []).forEach((h) => h(Buffer.from('-more'))); + setImmediate(() => (handlers['close'] || []).forEach((h) => h())); + }); + }), + }; + return c; + }); + + await forwardPackDataToRemote( + 'git-receive-pack /test/repo.git', + mockClientStream as any, + mockClient as ClientWithUser, + Buffer.from('pack'), + 10, + 'github.com', + ); + + // First chunk entirely skipped + // Second chunk: 5 bytes skipped, "real-data" forwarded + // Third chunk: forwarded entirely + expect(mockClientStream.write).toHaveBeenCalledWith(Buffer.from('real-data')); + expect(mockClientStream.write).toHaveBeenCalledWith(Buffer.from('-more')); + }); + + it('should handle null pack data', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const handlers: Record void)[]> = {}; + const mockRemoteStream = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!handlers[event]) handlers[event] = []; + handlers[event].push(handler); + }), + end: vi.fn(), + write: vi.fn(), + }; + + const mockClientStream = { + stderr: { write: vi.fn() }, + exit: vi.fn(), + end: vi.fn(), + write: vi.fn(), + }; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + setImmediate(() => { + (handlers['close'] || []).forEach((h) => h()); + }); + }), + }; + return c; + }); + + await forwardPackDataToRemote( + 'git-receive-pack /test/repo.git', + mockClientStream as any, + mockClient as ClientWithUser, + null, + 0, + 'github.com', + ); + + // write should not have been called with pack data since it's null + expect(mockRemoteStream.write).not.toHaveBeenCalled(); + expect(mockRemoteStream.end).toHaveBeenCalled(); + }); + }); + + describe('connectToRemoteGitServer', () => { + it('should set up bidirectional piping', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const remoteHandlers: Record void)[]> = {}; + const mockRemoteStream = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!remoteHandlers[event]) remoteHandlers[event] = []; + remoteHandlers[event].push(handler); + }), + end: vi.fn(), + write: vi.fn(), + }; + + const clientHandlers: Record void)[]> = {}; + const mockClientStream = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!clientHandlers[event]) clientHandlers[event] = []; + clientHandlers[event].push(handler); + }), + stderr: { write: vi.fn() }, + exit: vi.fn(), + end: vi.fn(), + write: vi.fn(), + }; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + setImmediate(() => { + // Simulate client sending data + (clientHandlers['data'] || []).forEach((h) => h(Buffer.from('client-data'))); + // Simulate remote sending data + (remoteHandlers['data'] || []).forEach((h) => h(Buffer.from('remote-data'))); + setImmediate(() => (remoteHandlers['close'] || []).forEach((h) => h())); + }); + }), + }; + return c; + }); + + await connectToRemoteGitServer( + 'git-upload-pack /test/repo.git', + mockClientStream as any, + mockClient as ClientWithUser, + 'github.com', + ); + + // Client data should be piped to remote + expect(mockRemoteStream.write).toHaveBeenCalledWith(Buffer.from('client-data')); + // Remote data should be piped to client + expect(mockClientStream.write).toHaveBeenCalledWith(Buffer.from('remote-data')); + }); + + it('should swallow early EOF errors in connectToRemoteGitServer error handler', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const remoteHandlers: Record void)[]> = {}; + const mockRemoteStream = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!remoteHandlers[event]) remoteHandlers[event] = []; + remoteHandlers[event].push(handler); + }), + end: vi.fn(), + write: vi.fn(), + }; + + const mockClientStream = { + on: vi.fn(), + stderr: { write: vi.fn() }, + exit: vi.fn(), + end: vi.fn(), + write: vi.fn(), + }; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + setImmediate(() => { + (remoteHandlers['close'] || []).forEach((h) => h()); + }); + }), + }; + return c; + }); + + await connectToRemoteGitServer( + 'git-upload-pack /test/repo.git', + mockClientStream as any, + mockClient as ClientWithUser, + 'github.com', + ); + + // Get the error handler registered by connectToRemoteGitServer + const errorHandlers = remoteHandlers['error'] || []; + const connectErrorHandler = errorHandlers[0]; + expect(connectErrorHandler).toBeDefined(); + + // Early EOF errors should be swallowed (not throw) + expect(() => connectErrorHandler(new Error('early EOF during git operation'))).not.toThrow(); + + // unexpected disconnect should also be swallowed + expect(() => + connectErrorHandler(new Error('unexpected disconnect from server')), + ).not.toThrow(); + }); + + it('should not swallow non-EOF errors from remote stream', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const remoteHandlers: Record void)[]> = {}; + const mockRemoteStream = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!remoteHandlers[event]) remoteHandlers[event] = []; + remoteHandlers[event].push(handler); + }), + end: vi.fn(), + write: vi.fn(), + }; + + const mockClientStream = { + on: vi.fn(), + stderr: { write: vi.fn() }, + exit: vi.fn(), + end: vi.fn(), + write: vi.fn(), + }; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + setImmediate(() => { + (remoteHandlers['close'] || []).forEach((h) => h()); + }); + }), + }; + return c; + }); + + await connectToRemoteGitServer( + 'git-upload-pack /test/repo.git', + mockClientStream as any, + mockClient as ClientWithUser, + 'github.com', + ); + + // Get the error handler registered by connectToRemoteGitServer on remoteStream + const errorHandlers = remoteHandlers['error'] || []; + // connectToRemoteGitServer's handler is the first one (registered in onStreamReady) + const connectErrorHandler = errorHandlers[0]; + expect(connectErrorHandler).toBeDefined(); + + // Non-EOF errors should throw (not be swallowed) + expect(() => connectErrorHandler(new Error('Fatal stream error'))).toThrow( + 'Fatal stream error', + ); + }); + }); + + describe('executeRemoteGitCommand edge cases', () => { + it('should handle exec error', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(new Error('exec failed')); + }), + }; + return c; + }); + + await expect( + fetchGitHubCapabilities( + 'git-upload-pack /test/repo.git', + mockClient as ClientWithUser, + 'github.com', + ), + ).rejects.toThrow('exec failed'); + }); + + it('should handle exec error with clientStream', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const mockClientStream = { + stderr: { write: vi.fn() }, + exit: vi.fn(), + end: vi.fn(), + write: vi.fn(), + }; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(new Error('exec failed on remote')); + }), + }; + return c; + }); + + await expect( + forwardPackDataToRemote( + 'git-receive-pack /test/repo.git', + mockClientStream as any, + mockClient as ClientWithUser, + Buffer.from('data'), + 0, + 'github.com', + ), + ).rejects.toThrow('exec failed on remote'); + + expect(mockClientStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('exec failed on remote'), + ); + expect(mockClientStream.exit).toHaveBeenCalledWith(1); + }); + + it('should handle stream error in executeRemoteGitCommand', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const remoteHandlers: Record void)[]> = {}; + const mockRemoteStream = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!remoteHandlers[event]) remoteHandlers[event] = []; + remoteHandlers[event].push(handler); + }), + end: vi.fn(), + write: vi.fn(), + }; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + setImmediate(() => { + // Fire the 'error' handler registered by executeRemoteGitCommand (not connectToRemoteGitServer) + // The last registered error handler is from executeRemoteGitCommand itself + const errorHandlers = remoteHandlers['error'] || []; + const baseErrorHandler = errorHandlers[errorHandlers.length - 1]; + if (baseErrorHandler) baseErrorHandler(new Error('stream broke')); + }); + }), + }; + return c; + }); + + await expect( + fetchGitHubCapabilities( + 'git-upload-pack /test/repo.git', + mockClient as ClientWithUser, + 'github.com', + ), + ).rejects.toThrow('stream broke'); + }); + + it('should handle remoteStream exit event with clientStream', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const remoteHandlers: Record void)[]> = {}; + const mockRemoteStream = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!remoteHandlers[event]) remoteHandlers[event] = []; + remoteHandlers[event].push(handler); + }), + end: vi.fn(), + write: vi.fn(), + }; + + const mockClientStream = { + on: vi.fn(), + stderr: { write: vi.fn() }, + exit: vi.fn(), + end: vi.fn(), + write: vi.fn(), + }; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + setImmediate(() => { + // Fire exit event with code + (remoteHandlers['exit'] || []).forEach((h) => h(0, null)); + setImmediate(() => (remoteHandlers['close'] || []).forEach((h) => h())); + }); + }), + }; + return c; + }); + + await connectToRemoteGitServer( + 'git-upload-pack /test/repo.git', + mockClientStream as any, + mockClient as ClientWithUser, + 'github.com', + ); + + expect(mockClientStream.exit).toHaveBeenCalledWith(0); + }); + + it('should handle connection error without clientStream', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'error') { + setImmediate(() => handler(new Error('Connection refused'))); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn(), + }; + return c; + }); + + // fetchGitHubCapabilities does NOT pass clientStream + await expect( + fetchGitHubCapabilities( + 'git-upload-pack /test/repo.git', + mockClient as ClientWithUser, + 'github.com', + ), + ).rejects.toThrow('Connection refused'); + }); + + it('should skip requireAgentForwarding when set to false', async () => { + const { validateSSHPrerequisites } = await import('../../src/proxy/ssh/sshHelpers'); + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + const handlers: Record void)[]> = {}; + const mockRemoteStream = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!handlers[event]) handlers[event] = []; + handlers[event].push(handler); + }), + end: vi.fn(), + write: vi.fn(), + }; + + Client.mockImplementation(() => { + const c = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'ready') { + setImmediate(() => handler()); + } + return c; + }), + connect: vi.fn(), + end: vi.fn(), + exec: vi.fn((_cmd: string, cb: (...args: unknown[]) => void) => { + cb(undefined, mockRemoteStream); + setImmediate(() => (handlers['close'] || []).forEach((h) => h())); + }), + }; + return c; + }); + + // connectToRemoteGitServer passes requireAgentForwarding: true + // fetchGitHubCapabilities does not pass it (defaults to true) + // Both call validateSSHPrerequisites + vi.mocked(validateSSHPrerequisites).mockClear(); + + await fetchGitHubCapabilities( + 'git-upload-pack /test/repo.git', + mockClient as ClientWithUser, + 'github.com', + ); + + expect(validateSSHPrerequisites).toHaveBeenCalled(); + }); + }); }); From ace0b07cdee833c0be7c5eb0a40df4ecc3c50aa8 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 1 Jul 2026 12:17:30 +0200 Subject: [PATCH 2/5] test(ssh): add server push operation, auth, and path validation tests --- test/ssh/server.test.ts | 458 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 458 insertions(+) diff --git a/test/ssh/server.test.ts b/test/ssh/server.test.ts index 2d6a77797..4196dfa4a 100644 --- a/test/ssh/server.test.ts +++ b/test/ssh/server.test.ts @@ -913,4 +913,462 @@ describe('SSHServer', () => { expect(acceptExec).toHaveBeenCalled(); }); }); + + describe('Repository Path Security', () => { + let mockStream: any; + let mockClient: any; + + beforeEach(() => { + mockStream = { + write: vi.fn(), + stderr: { write: vi.fn() }, + exit: vi.fn(), + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + }; + mockClient = { + authenticatedUser: { + username: 'test-user', + email: 'test@example.com', + gitAccount: 'testgit', + }, + agentForwardingEnabled: true, + clientIp: '127.0.0.1', + }; + }); + + it('should reject paths with path traversal sequences', async () => { + await server.handleCommand( + "git-upload-pack 'github.com/../secret/repo.git'", + mockStream, + mockClient, + ); + + expect(mockStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('path traversal'), + ); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + + it('should reject paths with double slashes', async () => { + await server.handleCommand( + "git-upload-pack 'github.com//test/repo.git'", + mockStream, + mockClient, + ); + + expect(mockStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('path traversal'), + ); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + + it('should reject paths with too few segments', async () => { + await server.handleCommand("git-upload-pack 'host.com/repo.git'", mockStream, mockClient); + + expect(mockStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('host/org/repo.git'), + ); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + + it('should reject paths with invalid hostname', async () => { + await server.handleCommand( + "git-upload-pack '-invalid.com/test/repo.git'", + mockStream, + mockClient, + ); + + expect(mockStream.stderr.write).toHaveBeenCalledWith(expect.stringContaining('valid domain')); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + + it('should reject paths without .git extension', async () => { + await server.handleCommand("git-upload-pack 'github.com/test/repo'", mockStream, mockClient); + + expect(mockStream.stderr.write).toHaveBeenCalledWith(expect.stringContaining('Error:')); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + }); + + describe('Push Operation - Full Flow', () => { + let mockStream: any; + let mockClient: any; + + beforeEach(() => { + mockStream = { + write: vi.fn(), + stderr: { write: vi.fn() }, + exit: vi.fn(), + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + }; + mockClient = { + authenticatedUser: { + username: 'test-user', + email: 'test@example.com', + gitAccount: 'testgit', + }, + agentForwardingEnabled: true, + clientIp: '127.0.0.1', + }; + }); + + type Handler = (...args: unknown[]) => void; + + async function setupPushHandlers() { + const handlers: Record = {}; + + vi.spyOn(GitProtocol, 'fetchGitHubCapabilities').mockResolvedValue( + Buffer.from('capabilities'), + ); + + mockStream.on.mockImplementation((event: string, handler: Handler) => { + handlers[`on:${event}`] = handler; + return mockStream; + }); + mockStream.once.mockImplementation((event: string, handler: Handler) => { + handlers[`once:${event}`] = handler; + return mockStream; + }); + + await server.handleCommand( + "git-receive-pack 'github.com/test/repo.git'", + mockStream, + mockClient, + ); + + return { + dataHandler: handlers['on:data'] as Handler, + endHandler: handlers['once:end'] as Handler, + errorHandler: handlers['on:error'] as Handler, + }; + } + + it('should detect no-op push when no data is sent', async () => { + const { endHandler } = await setupPushHandlers(); + + await endHandler(); + + expect(mockStream.exit).toHaveBeenCalledWith(0); + expect(mockStream.end).toHaveBeenCalled(); + }); + + it('should detect no-op push with flush-only pkt-line data', async () => { + const { dataHandler, endHandler } = await setupPushHandlers(); + + dataHandler(Buffer.from('0000')); + + await endHandler(); + + expect(mockStream.exit).toHaveBeenCalledWith(0); + expect(mockStream.end).toHaveBeenCalled(); + }); + + it('should run security chain and forward pack data on valid push', async () => { + const chainSpy = vi.spyOn(chain.default, 'executeChain').mockResolvedValue({ + error: false, + blocked: false, + } as any); + const forwardSpy = vi + .spyOn(GitProtocol, 'forwardPackDataToRemote') + .mockResolvedValue(undefined); + + const { dataHandler, endHandler } = await setupPushHandlers(); + + // Construct a proper pkt-line with a ref update + const oldSha = '0'.repeat(40); + const newSha = 'a'.repeat(40); + const refLine = `${oldSha} ${newSha} refs/heads/main\0report-status\n`; + const pktLen = (4 + refLine.length).toString(16).padStart(4, '0'); + const packData = Buffer.from(`${pktLen}${refLine}0000`); + + dataHandler(packData); + + await endHandler(); + + expect(chainSpy).toHaveBeenCalled(); + const req = chainSpy.mock.calls[0][0]; + expect(req.method).toBe('POST'); + expect(req.isSSH).toBe(true); + expect(forwardSpy).toHaveBeenCalled(); + }); + + it('should block push when security chain returns error', async () => { + vi.spyOn(chain.default, 'executeChain').mockResolvedValue({ + error: true, + errorMessage: 'Push blocked by policy', + } as any); + + const { dataHandler, endHandler } = await setupPushHandlers(); + + dataHandler(Buffer.from('arbitrary data triggers chain')); + + await endHandler(); + + expect(mockStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Push blocked by policy'), + ); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + + it('should block push when security chain returns blocked', async () => { + vi.spyOn(chain.default, 'executeChain').mockResolvedValue({ + blocked: true, + blockedMessage: 'Blocked by admin', + } as any); + + const { dataHandler, endHandler } = await setupPushHandlers(); + + dataHandler(Buffer.from('arbitrary data')); + + await endHandler(); + + expect(mockStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Blocked by admin'), + ); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + + it('should handle security chain execution error', async () => { + vi.spyOn(chain.default, 'executeChain').mockRejectedValue(new Error('Chain crashed')); + + const { dataHandler, endHandler } = await setupPushHandlers(); + + dataHandler(Buffer.from('arbitrary data')); + + await endHandler(); + + expect(mockStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Security chain execution failed'), + ); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + + it('should use default blocked message when none provided', async () => { + vi.spyOn(chain.default, 'executeChain').mockResolvedValue({ + blocked: true, + } as any); + + const { dataHandler, endHandler } = await setupPushHandlers(); + + dataHandler(Buffer.from('data')); + + await endHandler(); + + expect(mockStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Request blocked by proxy chain'), + ); + }); + + it('should enforce pack size limit', async () => { + vi.spyOn(config, 'getMaxPackSizeBytes').mockReturnValue(100); + + const { dataHandler } = await setupPushHandlers(); + + dataHandler(Buffer.alloc(101)); + + expect(mockStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('exceeds maximum size'), + ); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + + it('should enforce maximum chunk count', async () => { + const { dataHandler } = await setupPushHandlers(); + + for (let i = 0; i < 10000; i++) { + dataHandler(Buffer.from('x')); + } + + // Reset mocks to clearly see the rejection + mockStream.stderr.write.mockClear(); + mockStream.exit.mockClear(); + + dataHandler(Buffer.from('x')); + + expect(mockStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Exceeded maximum number of data chunks'), + ); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + + it('should reject non-Buffer data', async () => { + const { dataHandler } = await setupPushHandlers(); + + dataHandler('not a buffer' as any); + + expect(mockStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Invalid data format'), + ); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + + it('should handle stream error during push', async () => { + const { errorHandler } = await setupPushHandlers(); + + errorHandler(new Error('Connection reset')); + + expect(mockStream.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Connection reset'), + ); + expect(mockStream.exit).toHaveBeenCalledWith(1); + }); + + it('should timeout if push takes too long', async () => { + vi.useFakeTimers(); + + try { + const handlers: Record = {}; + vi.spyOn(GitProtocol, 'fetchGitHubCapabilities').mockResolvedValue(Buffer.from('caps')); + + mockStream.on.mockImplementation((event: string, handler: Handler) => { + handlers[`on:${event}`] = handler; + return mockStream; + }); + mockStream.once.mockImplementation((event: string, handler: Handler) => { + handlers[`once:${event}`] = handler; + return mockStream; + }); + + await server.handleCommand( + "git-receive-pack 'github.com/test/repo.git'", + mockStream, + mockClient, + ); + + vi.advanceTimersByTime(300001); + + expect(mockStream.stderr.write).toHaveBeenCalledWith('Error: Push operation timeout\n'); + expect(mockStream.exit).toHaveBeenCalledWith(1); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe('Authentication Edge Cases', () => { + let mockClient: any; + let clientInfo: any; + + beforeEach(() => { + mockClient = { + on: vi.fn(), + end: vi.fn(), + username: null, + agentForwardingEnabled: false, + authenticatedUser: null, + clientIp: null, + }; + clientInfo = { ip: '127.0.0.1', family: 'IPv4' }; + }); + + it('should handle database error during publickey auth', async () => { + const mockCtx = { + method: 'publickey', + key: { algo: 'ssh-rsa', data: Buffer.from('key-data') }, + accept: vi.fn(), + reject: vi.fn(), + }; + + vi.spyOn(db, 'findUserBySSHKey').mockRejectedValue(new Error('DB connection lost')); + + (server as any).handleClient(mockClient, clientInfo); + const authHandler = mockClient.on.mock.calls.find( + (call: any[]) => call[0] === 'authentication', + )?.[1]; + + authHandler(mockCtx); + // Rejection propagates through .then() → .catch(), needing two microtask ticks + await Promise.resolve(); + await Promise.resolve(); + + expect(mockCtx.reject).toHaveBeenCalled(); + expect(mockCtx.accept).not.toHaveBeenCalled(); + }); + + it('should reject unsupported authentication methods', async () => { + const mockCtx = { + method: 'password', + accept: vi.fn(), + reject: vi.fn(), + }; + + (server as any).handleClient(mockClient, clientInfo); + const authHandler = mockClient.on.mock.calls.find( + (call: any[]) => call[0] === 'authentication', + )?.[1]; + + authHandler(mockCtx); + + expect(mockCtx.reject).toHaveBeenCalled(); + expect(mockCtx.accept).not.toHaveBeenCalled(); + }); + }); + + describe('Agent Forwarding - wantReply=false', () => { + it('should handle auth-agent with non-function accept', () => { + const mockSession = { on: vi.fn(), end: vi.fn() }; + const mockClient: any = { + on: vi.fn(), + end: vi.fn(), + username: null, + agentForwardingEnabled: false, + authenticatedUser: { username: 'test-user', email: 'test@example.com' }, + clientIp: null, + }; + + (server as any).handleClient(mockClient, { ip: '127.0.0.1' }); + + const sessionHandler = mockClient.on.mock.calls.find( + (call: any[]) => call[0] === 'session', + )?.[1]; + + const accept = vi.fn().mockReturnValue(mockSession); + sessionHandler(accept, vi.fn()); + + const authAgentHandler = mockSession.on.mock.calls.find( + (call: any[]) => call[0] === 'auth-agent', + )?.[1]; + + // Pass non-function (simulates wantReply=false where ssh2 passes undefined) + authAgentHandler(undefined); + + // Should still enable agent forwarding even when internal APIs fail + expect(mockClient.agentForwardingEnabled).toBe(true); + }); + }); + + describe('Server Lifecycle - Listen Callback', () => { + it('should log when server starts listening', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn((server as any).server, 'listen').mockImplementation(((...args: unknown[]) => { + const cb = args[1] as () => void; + cb(); + }) as any); + + server.start(); + + expect(consoleSpy).toHaveBeenCalledWith('[SSH] Server listening on port 2222'); + }); + }); + + describe('Debug Mode', () => { + it('should create server with debug function when debug is enabled', () => { + vi.spyOn(config, 'getSSHConfig').mockReturnValue({ + hostKey: { + privateKeyPath: `${testKeysDir}/test_key`, + publicKeyPath: `${testKeysDir}/test_key.pub`, + }, + port: 2222, + enabled: true, + debug: true, + } as any); + + const debugServer = new SSHServer(); + expect(debugServer).toBeDefined(); + }); + }); }); From ef350361be77373f31501fbfacb9d47aeb386852 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 1 Jul 2026 12:18:04 +0200 Subject: [PATCH 3/5] test(ssh): add sshInternals and AgentForwarding channel confirmation tests --- test/ssh/AgentForwarding.test.ts | 172 ++++++++++++++++++++++++-- test/ssh/sshInternals.test.ts | 200 +++++++++++++++++++++++++++++++ 2 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 test/ssh/sshInternals.test.ts diff --git a/test/ssh/AgentForwarding.test.ts b/test/ssh/AgentForwarding.test.ts index b82720713..29e7aa15f 100644 --- a/test/ssh/AgentForwarding.test.ts +++ b/test/ssh/AgentForwarding.test.ts @@ -14,10 +14,15 @@ * limitations under the License. */ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; -import { LazySSHAgent, createLazyAgent } from '../../src/proxy/ssh/AgentForwarding'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { + LazySSHAgent, + createLazyAgent, + openTemporaryAgentChannel, +} from '../../src/proxy/ssh/AgentForwarding'; import { SSHAgentProxy } from '../../src/proxy/ssh/AgentProxy'; import { ClientWithUser } from '../../src/proxy/ssh/types'; +import * as sshInternals from '../../src/proxy/ssh/sshInternals'; describe('AgentForwarding', () => { let mockClient: Partial; @@ -322,8 +327,6 @@ describe('AgentForwarding', () => { describe('openTemporaryAgentChannel', () => { it('should return null when client has no protocol', async () => { - const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding'); - const clientWithoutProtocol: any = { agentForwardingEnabled: true, }; @@ -334,8 +337,6 @@ describe('AgentForwarding', () => { }); it('should handle timeout when channel confirmation not received', async () => { - const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding'); - const mockClient: any = { agentForwardingEnabled: true, _protocol: { @@ -356,8 +357,6 @@ describe('AgentForwarding', () => { }, 6000); it('should find next available channel ID when channels exist', async () => { - const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding'); - const mockClient: any = { agentForwardingEnabled: true, _protocol: { @@ -390,8 +389,6 @@ describe('AgentForwarding', () => { }, 6000); it('should use channel ID 1 when no channels exist', async () => { - const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding'); - const mockClient: any = { agentForwardingEnabled: true, _protocol: { @@ -417,8 +414,6 @@ describe('AgentForwarding', () => { }, 6000); it('should return null when client has no chanMgr', async () => { - const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding'); - const mockClient: any = { agentForwardingEnabled: true, _protocol: { @@ -426,7 +421,6 @@ describe('AgentForwarding', () => { openssh_authAgent: vi.fn(), channelSuccess: vi.fn(), }, - // No _chanMgr — the internals guard should reject and return null }; const result = await openTemporaryAgentChannel(mockClient); @@ -434,5 +428,157 @@ describe('AgentForwarding', () => { expect(result).toBeNull(); expect(mockClient._protocol.openssh_authAgent).not.toHaveBeenCalled(); }); + + describe('CHANNEL_OPEN_CONFIRMATION handler', () => { + let getChannelModuleSpy: ReturnType; + + afterEach(() => { + getChannelModuleSpy?.mockRestore(); + }); + + function makeMockClient(existingHandler?: (...args: unknown[]) => void) { + const handlers: Record void> = {}; + if (existingHandler) { + handlers.CHANNEL_OPEN_CONFIRMATION = existingHandler; + } + return { + _protocol: { + _handlers: handlers, + openssh_authAgent: vi.fn(), + channelSuccess: vi.fn(), + }, + _chanMgr: { _channels: {} as Record, _count: 0 }, + } as any; + } + + function mockChannelModule(channelImpl?: (...args: unknown[]) => unknown) { + const mockChannel = { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + getChannelModuleSpy = vi.spyOn(sshInternals, 'getChannelModule').mockReturnValue({ + Channel: channelImpl ?? vi.fn().mockReturnValue(mockChannel), + MAX_WINDOW: 2 * 1024 * 1024, + PACKET_SIZE: 32 * 1024, + }); + return mockChannel; + } + + it('should create AgentProxy on successful confirmation', async () => { + mockChannelModule(); + const client = makeMockClient(); + + const promise = openTemporaryAgentChannel(client); + + const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION; + handler(null, { recipient: 1, sender: 42, window: 65536, packetSize: 32768 }); + + const result = await promise; + + expect(result).toBeInstanceOf(SSHAgentProxy); + expect(client._chanMgr._channels[1]).toBeDefined(); + expect(client._chanMgr._count).toBe(1); + }); + + it('should call and restore original handler on confirmation', async () => { + mockChannelModule(); + const originalHandler = vi.fn(); + const client = makeMockClient(originalHandler); + + const promise = openTemporaryAgentChannel(client); + + const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION; + const confirmInfo = { recipient: 1, sender: 42, window: 65536, packetSize: 32768 }; + handler(null, confirmInfo); + + await promise; + + expect(originalHandler).toHaveBeenCalledWith(null, confirmInfo); + expect(client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION).toBe(originalHandler); + }); + + it('should delete handler when no original existed', async () => { + mockChannelModule(); + const client = makeMockClient(); + + const promise = openTemporaryAgentChannel(client); + + const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION; + handler(null, { recipient: 1, sender: 42, window: 65536, packetSize: 32768 }); + + await promise; + + expect(client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION).toBeUndefined(); + }); + + it('should ignore confirmation for non-matching channel recipient', async () => { + vi.useFakeTimers(); + try { + mockChannelModule(); + const client = makeMockClient(); + + const promise = openTemporaryAgentChannel(client); + + const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION; + handler(null, { recipient: 999, sender: 42, window: 65536, packetSize: 32768 }); + + vi.advanceTimersByTime(5001); + + const result = await promise; + expect(result).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('should resolve null when Channel constructor throws', async () => { + mockChannelModule(function () { + throw new Error('Channel creation failed'); + }); + const client = makeMockClient(); + + const promise = openTemporaryAgentChannel(client); + + const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION; + handler(null, { recipient: 1, sender: 42, window: 65536, packetSize: 32768 }); + + const result = await promise; + expect(result).toBeNull(); + }); + }); + }); + + describe('LazySSHAgent - lock recovery', () => { + it('should continue operating after a previous operation fails', () => { + return new Promise((resolve) => { + const openChannelFn = vi.fn(); + const client = { + agentForwardingEnabled: true, + clientIp: '127.0.0.1', + authenticatedUser: { username: 'testuser' }, + } as unknown as ClientWithUser; + + openChannelFn.mockRejectedValueOnce(new Error('Channel open failed')); + + const identities = [ + { publicKeyBlob: Buffer.from('key1'), comment: 'k', algorithm: 'ssh-ed25519' }, + ]; + const mockProxy = { + getIdentities: vi.fn().mockResolvedValue(identities), + sign: vi.fn(), + close: vi.fn(), + }; + openChannelFn.mockResolvedValueOnce(mockProxy); + + const agent = new LazySSHAgent(openChannelFn, client); + + agent.getIdentities((err) => { + expect(err).toBeDefined(); + + agent.getIdentities((err2, keys) => { + expect(err2).toBeNull(); + expect(keys).toHaveLength(1); + resolve(); + }); + }); + }); + }); }); }); diff --git a/test/ssh/sshInternals.test.ts b/test/ssh/sshInternals.test.ts new file mode 100644 index 000000000..14cff41d4 --- /dev/null +++ b/test/ssh/sshInternals.test.ts @@ -0,0 +1,200 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getProtocol, + getChannelManager, + findAvailableChannelId, + getChannelModule, + getSessionOutgoingChannelId, + getInstalledSsh2Version, +} from '../../src/proxy/ssh/sshInternals'; + +describe('sshInternals', () => { + describe('getProtocol', () => { + it('should return protocol when all internals are present', () => { + const mockClient = { + _protocol: { + openssh_authAgent: () => {}, + channelSuccess: () => {}, + _handlers: {}, + }, + }; + + const proto = getProtocol(mockClient as any); + expect(proto).toBe(mockClient._protocol); + }); + + it('should throw when _protocol is missing', () => { + expect(() => getProtocol({} as any)).toThrow('client._protocol is missing'); + }); + + it('should throw when openssh_authAgent is missing', () => { + const mockClient = { + _protocol: { + channelSuccess: () => {}, + _handlers: {}, + }, + }; + + expect(() => getProtocol(mockClient as any)).toThrow( + 'openssh_authAgent is missing or not a function', + ); + }); + + it('should throw when channelSuccess is missing', () => { + const mockClient = { + _protocol: { + openssh_authAgent: () => {}, + _handlers: {}, + }, + }; + + expect(() => getProtocol(mockClient as any)).toThrow( + 'channelSuccess is missing or not a function', + ); + }); + + it('should throw when _handlers is missing', () => { + const mockClient = { + _protocol: { + openssh_authAgent: () => {}, + channelSuccess: () => {}, + }, + }; + + expect(() => getProtocol(mockClient as any)).toThrow('_handlers is missing or not an object'); + }); + + it('should include ssh2 version in error messages', () => { + try { + getProtocol({} as any); + } catch (e) { + expect((e as Error).message).toContain('installed ssh2 version'); + expect((e as Error).message).toContain('verified working on'); + } + }); + }); + + describe('getChannelManager', () => { + it('should return channel manager when internals are present', () => { + const mockClient = { + _chanMgr: { + _channels: {}, + _count: 0, + }, + }; + + const chanMgr = getChannelManager(mockClient as any); + expect(chanMgr).toBe(mockClient._chanMgr); + }); + + it('should throw when _chanMgr is missing', () => { + expect(() => getChannelManager({} as any)).toThrow('client._chanMgr is missing'); + }); + + it('should throw when _channels is missing', () => { + const mockClient = { _chanMgr: { _count: 0 } }; + expect(() => getChannelManager(mockClient as any)).toThrow( + '_chanMgr._channels is missing or not an object', + ); + }); + + it('should throw when _count is missing', () => { + const mockClient = { _chanMgr: { _channels: {} } }; + expect(() => getChannelManager(mockClient as any)).toThrow( + '_chanMgr._count is missing or not a number', + ); + }); + }); + + describe('findAvailableChannelId', () => { + it('should return startId when no channels are in use', () => { + const chanMgr = { _channels: {}, _count: 0 }; + expect(findAvailableChannelId(chanMgr, 1)).toBe(1); + }); + + it('should skip occupied channel IDs', () => { + const chanMgr = { + _channels: { 1: 'occupied', 2: 'occupied', 3: 'occupied' } as any, + _count: 3, + }; + expect(findAvailableChannelId(chanMgr, 1)).toBe(4); + }); + + it('should return custom startId when specified', () => { + const chanMgr = { _channels: {}, _count: 0 }; + expect(findAvailableChannelId(chanMgr, 5)).toBe(5); + }); + + it('should find gaps in channel IDs', () => { + const chanMgr = { + _channels: { 1: 'a', 3: 'b' } as any, + _count: 2, + }; + expect(findAvailableChannelId(chanMgr, 1)).toBe(2); + }); + }); + + describe('getChannelModule', () => { + it('should return a valid channel module', () => { + const mod = getChannelModule(); + expect(typeof mod.Channel).toBe('function'); + expect(typeof mod.MAX_WINDOW).toBe('number'); + expect(typeof mod.PACKET_SIZE).toBe('number'); + }); + + it('should return cached module on subsequent calls', () => { + const first = getChannelModule(); + const second = getChannelModule(); + expect(first).toBe(second); + }); + }); + + describe('getSessionOutgoingChannelId', () => { + it('should return channel id when _chanInfo is present', () => { + const session = { _chanInfo: { outgoing: { id: 42 } } }; + expect(getSessionOutgoingChannelId(session)).toBe(42); + }); + + it('should return undefined when _chanInfo is missing', () => { + expect(getSessionOutgoingChannelId({})).toBeUndefined(); + }); + + it('should return undefined when outgoing is missing', () => { + const session = { _chanInfo: {} }; + expect(getSessionOutgoingChannelId(session)).toBeUndefined(); + }); + + it('should return undefined when id is not a number', () => { + const session = { _chanInfo: { outgoing: { id: 'not-a-number' } } }; + expect(getSessionOutgoingChannelId(session)).toBeUndefined(); + }); + + it('should return undefined for null session', () => { + expect(getSessionOutgoingChannelId(null)).toBeUndefined(); + }); + }); + + describe('getInstalledSsh2Version', () => { + it('should return a version string', () => { + const version = getInstalledSsh2Version(); + expect(typeof version).toBe('string'); + expect(version.length).toBeGreaterThan(0); + }); + }); +}); From 471e7749f02e831f53abe98ab4760d391d28fdf0 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Fri, 3 Jul 2026 11:07:47 +0200 Subject: [PATCH 4/5] test(ssh): add AgentProxy protocol parsing edge case tests --- test/ssh/AgentProxy.test.ts | 195 ++++++++++++++++++++++++++++++++++-- 1 file changed, 188 insertions(+), 7 deletions(-) diff --git a/test/ssh/AgentProxy.test.ts b/test/ssh/AgentProxy.test.ts index a2ae27439..5f9d08866 100644 --- a/test/ssh/AgentProxy.test.ts +++ b/test/ssh/AgentProxy.test.ts @@ -152,15 +152,24 @@ describe('SSHAgentProxy', () => { }); it('should timeout when agent does not respond', async () => { - agentProxy = new SSHAgentProxy(mockChannel as any); + vi.useFakeTimers(); + try { + agentProxy = new SSHAgentProxy(mockChannel as any); - mockChannel.write.mockImplementation(() => { - // Don't send any response, causing timeout - return true; - }); + mockChannel.write.mockImplementation(() => { + // Don't send any response, causing timeout + return true; + }); - await expect(agentProxy.getIdentities()).rejects.toThrow('Agent request timeout'); - }, 15000); + const assertion = expect(agentProxy.getIdentities()).rejects.toThrow( + 'Agent request timeout', + ); + await vi.advanceTimersByTimeAsync(10001); + await assertion; + } finally { + vi.useRealTimers(); + } + }); it('should throw error for invalid identities response - too short', async () => { agentProxy = new SSHAgentProxy(mockChannel as any); @@ -297,6 +306,178 @@ describe('SSHAgentProxy', () => { }); }); + describe('sign - edge cases', () => { + function sendResponse(response: Buffer) { + mockChannel.write.mockImplementation(() => { + setImmediate(() => { + const len = Buffer.allocUnsafe(4); + len.writeUInt32BE(response.length, 0); + mockChannel.emit('data', Buffer.concat([len, response])); + }); + return true; + }); + } + + const pubKey = Buffer.alloc(32, 0x41); + const data = Buffer.from('data'); + + it('should throw error for unexpected sign response type', async () => { + agentProxy = new SSHAgentProxy(mockChannel as any); + sendResponse(Buffer.from([99])); + + await expect(agentProxy.sign(pubKey, data)).rejects.toThrow('Unexpected response type: 99'); + }); + + it('should throw error for incomplete signature blob', async () => { + agentProxy = new SSHAgentProxy(mockChannel as any); + // sig_blob_len=100 but only 2 bytes of data + sendResponse( + Buffer.concat([Buffer.from([14]), Buffer.from([0, 0, 0, 100]), Buffer.from([0, 0])]), + ); + + await expect(agentProxy.sign(pubKey, data)).rejects.toThrow( + 'Invalid sign response: incomplete signature', + ); + }); + + it('should throw error when sig blob too short for algo and sig length', async () => { + agentProxy = new SSHAgentProxy(mockChannel as any); + // sigBlob: algoLen=3 + 'rsa' but no sig_len field after → total 7 bytes < 4+3+4=11 + const sigBlob = Buffer.concat([Buffer.from([0, 0, 0, 3]), Buffer.from('rsa')]); + sendResponse( + Buffer.concat([Buffer.from([14]), Buffer.from([0, 0, 0, sigBlob.length]), sigBlob]), + ); + + await expect(agentProxy.sign(pubKey, data)).rejects.toThrow( + 'Invalid signature blob: too short for algo and sig length', + ); + }); + + it('should throw error when signature bytes are incomplete', async () => { + agentProxy = new SSHAgentProxy(mockChannel as any); + // sigBlob: algoLen=3 + 'rsa' + sigLen=50 but only 1 byte of sig + const sigBlob = Buffer.concat([ + Buffer.from([0, 0, 0, 3]), + Buffer.from('rsa'), + Buffer.from([0, 0, 0, 50]), + Buffer.from([0x01]), + ]); + sendResponse( + Buffer.concat([Buffer.from([14]), Buffer.from([0, 0, 0, sigBlob.length]), sigBlob]), + ); + + await expect(agentProxy.sign(pubKey, data)).rejects.toThrow( + 'Invalid signature blob: incomplete signature bytes', + ); + }); + }); + + describe('parseIdentities - edge cases', () => { + function sendResponse(response: Buffer) { + mockChannel.write.mockImplementation(() => { + setImmediate(() => { + const len = Buffer.allocUnsafe(4); + len.writeUInt32BE(response.length, 0); + mockChannel.emit('data', Buffer.concat([len, response])); + }); + return true; + }); + } + + it('should throw when key blob length is missing', async () => { + agentProxy = new SSHAgentProxy(mockChannel as any); + // type=12 + num_keys=1 but no blob length + sendResponse(Buffer.from([12, 0, 0, 0, 1])); + + await expect(agentProxy.getIdentities()).rejects.toThrow('missing key blob length for key 0'); + }); + + it('should throw when key blob is incomplete', async () => { + agentProxy = new SSHAgentProxy(mockChannel as any); + // type=12 + num_keys=1 + blob_len=10 but only 2 bytes of blob + sendResponse(Buffer.from([12, 0, 0, 0, 1, 0, 0, 0, 10, 0x41, 0x42])); + + await expect(agentProxy.getIdentities()).rejects.toThrow('incomplete key blob for key 0'); + }); + + it('should throw when comment length is missing', async () => { + agentProxy = new SSHAgentProxy(mockChannel as any); + // type=12 + num_keys=1 + blob_len=2 + blob(2 bytes) but no comment_len + sendResponse(Buffer.from([12, 0, 0, 0, 1, 0, 0, 0, 2, 0x41, 0x42])); + + await expect(agentProxy.getIdentities()).rejects.toThrow('missing comment length for key 0'); + }); + + it('should throw when comment is incomplete', async () => { + agentProxy = new SSHAgentProxy(mockChannel as any); + // type=12 + num_keys=1 + blob_len=2 + blob + comment_len=10 + only 1 byte + sendResponse(Buffer.from([12, 0, 0, 0, 1, 0, 0, 0, 2, 0x41, 0x42, 0, 0, 0, 10, 0x43])); + + await expect(agentProxy.getIdentities()).rejects.toThrow('incomplete comment for key 0'); + }); + + it('should set algorithm to unknown when key blob is too short', async () => { + agentProxy = new SSHAgentProxy(mockChannel as any); + // key blob of 2 bytes (< 4, can't read algo length) + const keyBlob = Buffer.from([0x01, 0x02]); + sendResponse( + Buffer.concat([ + Buffer.from([12, 0, 0, 0, 1]), + Buffer.from([0, 0, 0, keyBlob.length]), + keyBlob, + Buffer.from([0, 0, 0, 1]), + Buffer.from('k'), + ]), + ); + + const ids = await agentProxy.getIdentities(); + expect(ids[0].algorithm).toBe('unknown'); + }); + + it('should set algorithm to unknown when algo data is incomplete', async () => { + agentProxy = new SSHAgentProxy(mockChannel as any); + // key blob: algoLen=10 but only 1 byte of algo data + const keyBlob = Buffer.from([0, 0, 0, 10, 0x41]); + sendResponse( + Buffer.concat([ + Buffer.from([12, 0, 0, 0, 1]), + Buffer.from([0, 0, 0, keyBlob.length]), + keyBlob, + Buffer.from([0, 0, 0, 1]), + Buffer.from('k'), + ]), + ); + + const ids = await agentProxy.getIdentities(); + expect(ids[0].algorithm).toBe('unknown'); + }); + }); + + describe('handleMessage - edge cases', () => { + it('should ignore empty messages from agent', async () => { + agentProxy = new SSHAgentProxy(mockChannel as any); + + const validResponse = Buffer.from([12, 0, 0, 0, 0]); // empty identities + + mockChannel.write.mockImplementation(() => { + setImmediate(() => { + // Send empty message first (length=0) + mockChannel.emit('data', Buffer.from([0, 0, 0, 0])); + // Then send valid response + setImmediate(() => { + const len = Buffer.allocUnsafe(4); + len.writeUInt32BE(validResponse.length, 0); + mockChannel.emit('data', Buffer.concat([len, validResponse])); + }); + }); + return true; + }); + + const ids = await agentProxy.getIdentities(); + expect(ids).toHaveLength(0); + }); + }); + describe('close', () => { it('should close channel and remove listeners', () => { agentProxy = new SSHAgentProxy(mockChannel as any); From ceb048e708b5833bb7d0aadfde3a5fc3a71f11f5 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Fri, 3 Jul 2026 11:08:16 +0200 Subject: [PATCH 5/5] test(ssh): harden SSH test assertions and timers --- test/ssh/AgentForwarding.test.ts | 143 +++++++++++++++++-------------- test/ssh/GitProtocol.test.ts | 40 ++++----- test/ssh/sshInternals.test.ts | 8 +- 3 files changed, 99 insertions(+), 92 deletions(-) diff --git a/test/ssh/AgentForwarding.test.ts b/test/ssh/AgentForwarding.test.ts index 29e7aa15f..96e4feb9e 100644 --- a/test/ssh/AgentForwarding.test.ts +++ b/test/ssh/AgentForwarding.test.ts @@ -337,81 +337,100 @@ describe('AgentForwarding', () => { }); it('should handle timeout when channel confirmation not received', async () => { - const mockClient: any = { - agentForwardingEnabled: true, - _protocol: { - _handlers: {}, - openssh_authAgent: vi.fn(), - channelSuccess: vi.fn(), - }, - _chanMgr: { - _channels: {}, - _count: 0, - }, - }; + vi.useFakeTimers(); + try { + const mockClient: any = { + agentForwardingEnabled: true, + _protocol: { + _handlers: {}, + openssh_authAgent: vi.fn(), + channelSuccess: vi.fn(), + }, + _chanMgr: { + _channels: {}, + _count: 0, + }, + }; - const result = await openTemporaryAgentChannel(mockClient); + const promise = openTemporaryAgentChannel(mockClient); - // Should timeout and return null after 5 seconds - expect(result).toBeNull(); - }, 6000); + // Advance past the 5s confirmation window instead of waiting in real time + vi.advanceTimersByTime(5001); + const result = await promise; + + expect(result).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); it('should find next available channel ID when channels exist', async () => { - const mockClient: any = { - agentForwardingEnabled: true, - _protocol: { - _handlers: {}, - openssh_authAgent: vi.fn(), - channelSuccess: vi.fn(), - }, - _chanMgr: { - _channels: { - 1: 'occupied', - 2: 'occupied', - // Channel 3 should be used + vi.useFakeTimers(); + try { + const mockClient: any = { + agentForwardingEnabled: true, + _protocol: { + _handlers: {}, + openssh_authAgent: vi.fn(), + channelSuccess: vi.fn(), }, - _count: 2, - }, - }; + _chanMgr: { + _channels: { + 1: 'occupied', + 2: 'occupied', + // Channel 3 should be used + }, + _count: 2, + }, + }; - // Start the operation but don't wait for completion (will timeout) - const promise = openTemporaryAgentChannel(mockClient); + const promise = openTemporaryAgentChannel(mockClient); - // Verify openssh_authAgent was called with the next available channel (3) - expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith( - 3, - expect.any(Number), - expect.any(Number), - ); + // openssh_authAgent is called synchronously, before the promise settles + expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith( + 3, + expect.any(Number), + expect.any(Number), + ); - // Clean up - wait for timeout - await promise; - }, 6000); + // Advance past the confirmation timeout so the promise settles cleanly + vi.advanceTimersByTime(5001); + await promise; + } finally { + vi.useRealTimers(); + } + }); it('should use channel ID 1 when no channels exist', async () => { - const mockClient: any = { - agentForwardingEnabled: true, - _protocol: { - _handlers: {}, - openssh_authAgent: vi.fn(), - channelSuccess: vi.fn(), - }, - _chanMgr: { - _channels: {}, - _count: 0, - }, - }; + vi.useFakeTimers(); + try { + const mockClient: any = { + agentForwardingEnabled: true, + _protocol: { + _handlers: {}, + openssh_authAgent: vi.fn(), + channelSuccess: vi.fn(), + }, + _chanMgr: { + _channels: {}, + _count: 0, + }, + }; - const promise = openTemporaryAgentChannel(mockClient); + const promise = openTemporaryAgentChannel(mockClient); - expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith( - 1, - expect.any(Number), - expect.any(Number), - ); + expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith( + 1, + expect.any(Number), + expect.any(Number), + ); - await promise; - }, 6000); + vi.advanceTimersByTime(5001); + await promise; + } finally { + vi.useRealTimers(); + } + }); it('should return null when client has no chanMgr', async () => { const mockClient: any = { diff --git a/test/ssh/GitProtocol.test.ts b/test/ssh/GitProtocol.test.ts index b004c58b6..eb1944cbd 100644 --- a/test/ssh/GitProtocol.test.ts +++ b/test/ssh/GitProtocol.test.ts @@ -179,15 +179,13 @@ describe('GitProtocol', () => { return mockClient; }); - try { - await fetchGitHubCapabilities( + await expect( + fetchGitHubCapabilities( 'git-upload-pack /test/repo.git', mockClient as ClientWithUser, 'github.com', - ); - } catch (e) { - // Expected to fail - } + ), + ).rejects.toThrow('Test error'); expect(validateSSHPrerequisites).toHaveBeenCalledWith(mockClient); }); @@ -227,18 +225,16 @@ describe('GitProtocol', () => { // Import the function that uses clientStream const { forwardPackDataToRemote } = await import('../../src/proxy/ssh/GitProtocol'); - try { - await forwardPackDataToRemote( + await expect( + forwardPackDataToRemote( 'git-receive-pack /test/repo.git', mockStream as any, mockClient as ClientWithUser, Buffer.from('test'), 0, 'github.com', - ); - } catch (e) { - // Expected to fail - } + ), + ).rejects.toThrow('All configured authentication methods failed'); // Check that helpful error message was written to stderr expect(mockStream.stderr.write).toHaveBeenCalled(); @@ -278,18 +274,16 @@ describe('GitProtocol', () => { const { forwardPackDataToRemote } = await import('../../src/proxy/ssh/GitProtocol'); - try { - await forwardPackDataToRemote( + await expect( + forwardPackDataToRemote( 'git-receive-pack /test/repo.git', mockStream as any, mockClient as ClientWithUser, Buffer.from('test'), 0, 'bitbucket.org', - ); - } catch (e) { - // Expected to fail - } + ), + ).rejects.toThrow('All configured authentication methods failed'); expect(mockStream.stderr.write).toHaveBeenCalled(); const errorMessage = mockStream.stderr.write.mock.calls[0][0]; @@ -329,18 +323,16 @@ describe('GitProtocol', () => { const { forwardPackDataToRemote } = await import('../../src/proxy/ssh/GitProtocol'); - try { - await forwardPackDataToRemote( + await expect( + forwardPackDataToRemote( 'git-receive-pack /test/repo.git', mockStream as any, mockClient as ClientWithUser, Buffer.from('test'), 0, 'gitlab.com', - ); - } catch (e) { - // Expected to fail - } + ), + ).rejects.toThrow('All configured authentication methods failed'); expect(mockStream.stderr.write).toHaveBeenCalled(); const errorMessage = mockStream.stderr.write.mock.calls[0][0]; diff --git a/test/ssh/sshInternals.test.ts b/test/ssh/sshInternals.test.ts index 14cff41d4..771397065 100644 --- a/test/ssh/sshInternals.test.ts +++ b/test/ssh/sshInternals.test.ts @@ -81,12 +81,8 @@ describe('sshInternals', () => { }); it('should include ssh2 version in error messages', () => { - try { - getProtocol({} as any); - } catch (e) { - expect((e as Error).message).toContain('installed ssh2 version'); - expect((e as Error).message).toContain('verified working on'); - } + expect(() => getProtocol({} as any)).toThrow(/installed ssh2 version/); + expect(() => getProtocol({} as any)).toThrow(/verified working on/); }); });