diff --git a/test/ssh/AgentForwarding.test.ts b/test/ssh/AgentForwarding.test.ts index b82720713..2b23994ed 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,102 @@ describe('AgentForwarding', () => { }); it('should handle timeout when channel confirmation not received', async () => { - const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding'); + 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); + + // 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 () => { + vi.useFakeTimers(); + try { + 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 + }, + _count: 2, + }, + }; + + const promise = openTemporaryAgentChannel(mockClient); + + // openssh_authAgent is called synchronously, before the promise settles + expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith( + 3, + expect.any(Number), + expect.any(Number), + ); + + // 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 () => { + 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); + + expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith( + 1, + expect.any(Number), + expect.any(Number), + ); + + vi.advanceTimersByTime(5001); + await promise; + } finally { + vi.useRealTimers(); + } + }); + + it('should return null when client has no chanMgr', async () => { const mockClient: any = { agentForwardingEnabled: true, _protocol: { @@ -343,96 +440,164 @@ describe('AgentForwarding', () => { openssh_authAgent: vi.fn(), channelSuccess: vi.fn(), }, - _chanMgr: { - _channels: {}, - _count: 0, - }, }; const result = await openTemporaryAgentChannel(mockClient); - // Should timeout and return null after 5 seconds expect(result).toBeNull(); - }, 6000); + expect(mockClient._protocol.openssh_authAgent).not.toHaveBeenCalled(); + }); - it('should find next available channel ID when channels exist', async () => { - const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding'); + describe('CHANNEL_OPEN_CONFIRMATION handler', () => { + let getChannelModuleSpy: ReturnType; - 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 + 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(), }, - _count: 2, - }, - }; + _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').mockImplementation(() => ({ + Channel: channelImpl ?? vi.fn().mockImplementation(() => 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); + }); - // Start the operation but don't wait for completion (will timeout) - const promise = openTemporaryAgentChannel(mockClient); + it('should call and restore original handler on confirmation', async () => { + mockChannelModule(); + const originalHandler = vi.fn(); + const client = makeMockClient(originalHandler); - // Verify openssh_authAgent was called with the next available channel (3) - expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith( - 3, - expect.any(Number), - expect.any(Number), - ); + const promise = openTemporaryAgentChannel(client); - // Clean up - wait for timeout - await promise; - }, 6000); + const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION; + const confirmInfo = { recipient: 1, sender: 42, window: 65536, packetSize: 32768 }; + handler(null, confirmInfo); - it('should use channel ID 1 when no channels exist', async () => { - const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding'); + await promise; - const mockClient: any = { - agentForwardingEnabled: true, - _protocol: { - _handlers: {}, - openssh_authAgent: vi.fn(), - channelSuccess: vi.fn(), - }, - _chanMgr: { - _channels: {}, - _count: 0, - }, - }; + expect(originalHandler).toHaveBeenCalledWith(null, confirmInfo); + expect(client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION).toBe(originalHandler); + }); - const promise = openTemporaryAgentChannel(mockClient); + it('should delete handler when no original existed', async () => { + mockChannelModule(); + const client = makeMockClient(); - expect(mockClient._protocol.openssh_authAgent).toHaveBeenCalledWith( - 1, - expect.any(Number), - expect.any(Number), - ); + const promise = openTemporaryAgentChannel(client); - await promise; - }, 6000); + const handler = client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION; + handler(null, { recipient: 1, sender: 42, window: 65536, packetSize: 32768 }); - it('should return null when client has no chanMgr', async () => { - const { openTemporaryAgentChannel } = await import('../../src/proxy/ssh/AgentForwarding'); + await promise; - const mockClient: any = { - agentForwardingEnabled: true, - _protocol: { - _handlers: {}, - openssh_authAgent: vi.fn(), - channelSuccess: vi.fn(), - }, - // No _chanMgr — the internals guard should reject and return null - }; + expect(client._protocol._handlers.CHANNEL_OPEN_CONFIRMATION).toBeUndefined(); + }); - const result = await openTemporaryAgentChannel(mockClient); + it('should ignore confirmation for non-matching channel recipient', async () => { + vi.useFakeTimers(); + try { + mockChannelModule(); + const client = makeMockClient(); - expect(result).toBeNull(); - expect(mockClient._protocol.openssh_authAgent).not.toHaveBeenCalled(); + 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/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); diff --git a/test/ssh/GitProtocol.test.ts b/test/ssh/GitProtocol.test.ts index 3dbba87f4..481e12729 100644 --- a/test/ssh/GitProtocol.test.ts +++ b/test/ssh/GitProtocol.test.ts @@ -36,9 +36,20 @@ 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'; +import { parsePacketLines } from '../../src/proxy/processors/pktLineParser'; describe('GitProtocol', () => { let mockClient: Partial; @@ -169,15 +180,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); }); @@ -217,18 +226,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(); @@ -237,6 +244,54 @@ 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'); + + await expect( + forwardPackDataToRemote( + 'git-receive-pack /test/repo.git', + mockStream as any, + mockClient as ClientWithUser, + Buffer.from('test'), + 0, + 'bitbucket.org', + ), + ).rejects.toThrow('All configured authentication methods failed'); + + 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; @@ -269,18 +324,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]; @@ -288,4 +341,800 @@ 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. + // "hello world\n" is 12 bytes, so the total packet length is 4 + 12 = 16 = 0x0010. + // Resulting stream: "0010hello 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); + // Decode the collected buffer with the real pkt-line parser to prove it is a + // well-formed stream, not just that the raw bytes happen to contain the payload. + const [lines, offset] = parsePacketLines(result); + expect(lines).toEqual(['hello world\n']); + // The parser must consume the trailing flush packet exactly at the buffer end. + expect(offset).toBe(result.length); + expect(mockRemoteStream.end).toHaveBeenCalled(); + }); + + it('should accumulate multiple data chunks before flush', async () => { + const ssh2 = await import('ssh2'); + const Client = ssh2.Client as any; + + // Each payload "chunk one\n" / "chunk two\n" is 10 bytes, so the pkt-line length + // prefix is 4 + 10 = 14 = 0x000e. The second chunk is followed by the flush packet. + const chunk1 = Buffer.from('000echunk one\n'); + const chunk2 = Buffer.from('000echunk 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', + ); + + // The two data events must be accumulated and decode as two distinct pkt-lines, + // with the trailing flush packet consumed exactly at the end of the buffer. + const [lines, offset] = parsePacketLines(result); + expect(lines).toEqual(['chunk one\n', 'chunk two\n']); + expect(offset).toBe(result.length); + }); + }); + + 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 validate SSH prerequisites by default (requireAgentForwarding defaults to true)', 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(); + }); + }); }); diff --git a/test/ssh/server.test.ts b/test/ssh/server.test.ts index 2d6a77797..2a4956884 100644 --- a/test/ssh/server.test.ts +++ b/test/ssh/server.test.ts @@ -20,9 +20,23 @@ import fs from 'fs'; import * as config from '../../src/config'; import * as db from '../../src/db'; import * as chain from '../../src/proxy/chain'; +import * as ssh2 from 'ssh2'; import SSHServer from '../../src/proxy/ssh/server'; import * as GitProtocol from '../../src/proxy/ssh/GitProtocol'; +// Wrap the ssh2.Server constructor in a spy while keeping the real implementation, +// so we can inspect the options it is built with. Its namespace export cannot be +// patched with vi.spyOn under ESM, hence the module factory. +vi.mock('ssh2', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Server: vi.fn( + (...args: ConstructorParameters) => new actual.Server(...args), + ), + }; +}); + /** * SSH Server Unit Test Suite * @@ -913,4 +927,492 @@ 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); + + // Inspect the options passed to the ssh2.Server constructor (spied via vi.mock). + const serverSpy = vi.mocked(ssh2.Server); + serverSpy.mockClear(); + + const debugServer = new SSHServer(); + + expect(debugServer).toBeDefined(); + // Debug mode is only observable through the options passed to ssh2.Server: + // when enabled, serverOptions.debug is a logging function. + const serverOptions = serverSpy.mock.calls[0][0] as any; + expect(typeof serverOptions.debug).toBe('function'); + }); + + it('should not set a debug function when debug is disabled', () => { + vi.spyOn(config, 'getSSHConfig').mockReturnValue({ + hostKey: { + privateKeyPath: `${testKeysDir}/test_key`, + publicKeyPath: `${testKeysDir}/test_key.pub`, + }, + port: 2222, + enabled: true, + debug: false, + } as any); + + const serverSpy = vi.mocked(ssh2.Server); + serverSpy.mockClear(); + + const plainServer = new SSHServer(); + + expect(plainServer).toBeDefined(); + const serverOptions = serverSpy.mock.calls[0][0] as any; + expect(serverOptions.debug).toBeUndefined(); + }); + }); }); diff --git a/test/ssh/sshInternals.test.ts b/test/ssh/sshInternals.test.ts new file mode 100644 index 000000000..771397065 --- /dev/null +++ b/test/ssh/sshInternals.test.ts @@ -0,0 +1,196 @@ +/** + * 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', () => { + expect(() => getProtocol({} as any)).toThrow(/installed ssh2 version/); + expect(() => getProtocol({} as any)).toThrow(/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); + }); + }); +});