Skip to content

Commit 8c3cae1

Browse files
committed
feat: adds test for a ssh server
1 parent cb82567 commit 8c3cae1

File tree

2 files changed

+345
-3
lines changed

2 files changed

+345
-3
lines changed

src/proxy/ssh/server.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,13 +191,14 @@ class SSHServer {
191191
.replace(/^\/+|\/+$/g, '');
192192

193193
const req = {
194-
method: command === 'git-upload-pack' ? 'GET' : 'POST',
194+
method: command.startsWith('git-upload-pack') ? 'GET' : 'POST',
195195
originalUrl: repoPath,
196196
isSSH: true,
197197
headers: {
198198
'user-agent': 'git/2.0.0',
199-
'content-type':
200-
command === 'git-receive-pack' ? 'application/x-git-receive-pack-request' : undefined,
199+
'content-type': command.startsWith('git-receive-pack')
200+
? 'application/x-git-receive-pack-request'
201+
: undefined,
201202
},
202203
};
203204

test/ssh/server.test.js

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
const chai = require('chai');
2+
const sinon = require('sinon');
3+
const expect = chai.expect;
4+
const fs = require('fs');
5+
const ssh2 = require('ssh2');
6+
const config = require('../../src/config');
7+
const db = require('../../src/db');
8+
const chain = require('../../src/proxy/chain');
9+
const SSHServer = require('../../src/proxy/ssh/server');
10+
const { execSync } = require('child_process');
11+
12+
describe('SSHServer', () => {
13+
let server;
14+
let mockConfig;
15+
let mockDb;
16+
let mockChain;
17+
let mockSsh2Server;
18+
let mockFs;
19+
const testKeysDir = 'test/keys';
20+
let testKeyContent;
21+
22+
before(() => {
23+
// Create directory for test keys
24+
if (!fs.existsSync(testKeysDir)) {
25+
fs.mkdirSync(testKeysDir, { recursive: true });
26+
}
27+
// Generate test SSH key pair
28+
execSync(`ssh-keygen -t rsa -b 4096 -f ${testKeysDir}/test_key -N "" -C "test@git-proxy"`);
29+
// Read the key once and store it
30+
testKeyContent = fs.readFileSync(`${testKeysDir}/test_key`);
31+
});
32+
33+
after(() => {
34+
// Clean up test keys
35+
if (fs.existsSync(testKeysDir)) {
36+
fs.rmSync(testKeysDir, { recursive: true, force: true });
37+
}
38+
});
39+
40+
beforeEach(() => {
41+
// Create stubs for all dependencies
42+
mockConfig = {
43+
getSSHConfig: sinon.stub().returns({
44+
hostKey: {
45+
privateKeyPath: `${testKeysDir}/test_key`,
46+
publicKeyPath: `${testKeysDir}/test_key.pub`,
47+
},
48+
port: 22,
49+
}),
50+
getProxyUrl: sinon.stub().returns('https://github.com'),
51+
};
52+
53+
mockDb = {
54+
findUserBySSHKey: sinon.stub(),
55+
findUser: sinon.stub(),
56+
};
57+
58+
mockChain = {
59+
executeChain: sinon.stub(),
60+
};
61+
62+
mockFs = {
63+
readFileSync: sinon.stub().callsFake((path) => {
64+
if (path === `${testKeysDir}/test_key`) {
65+
return testKeyContent;
66+
}
67+
return 'mock-key-data';
68+
}),
69+
};
70+
71+
// Create a more complete mock for the SSH2 server
72+
mockSsh2Server = {
73+
Server: sinon.stub().returns({
74+
listen: sinon.stub(),
75+
on: sinon.stub(),
76+
}),
77+
};
78+
79+
// Replace the real modules with our stubs
80+
sinon.stub(config, 'getSSHConfig').callsFake(mockConfig.getSSHConfig);
81+
sinon.stub(config, 'getProxyUrl').callsFake(mockConfig.getProxyUrl);
82+
sinon.stub(db, 'findUserBySSHKey').callsFake(mockDb.findUserBySSHKey);
83+
sinon.stub(db, 'findUser').callsFake(mockDb.findUser);
84+
sinon.stub(chain, 'executeChain').callsFake(mockChain.executeChain);
85+
sinon.stub(fs, 'readFileSync').callsFake(mockFs.readFileSync);
86+
sinon.stub(ssh2, 'Server').callsFake(mockSsh2Server.Server);
87+
88+
server = new SSHServer();
89+
});
90+
91+
afterEach(() => {
92+
// Restore all stubs
93+
sinon.restore();
94+
});
95+
96+
describe('constructor', () => {
97+
it('should create a new SSH2 server with correct configuration', () => {
98+
expect(ssh2.Server.calledOnce).to.be.true;
99+
const serverConfig = ssh2.Server.firstCall.args[0];
100+
expect(serverConfig.hostKeys).to.be.an('array');
101+
expect(serverConfig.authMethods).to.deep.equal(['publickey', 'password']);
102+
expect(serverConfig.keepaliveInterval).to.equal(5000);
103+
expect(serverConfig.keepaliveCountMax).to.equal(10);
104+
expect(serverConfig.readyTimeout).to.equal(30000);
105+
});
106+
});
107+
108+
describe('start', () => {
109+
it('should start listening on the configured port', () => {
110+
server.start();
111+
expect(server.server.listen.calledWith(22, '0.0.0.0')).to.be.true;
112+
});
113+
});
114+
115+
describe('handleClient', () => {
116+
let mockClient;
117+
118+
beforeEach(() => {
119+
mockClient = {
120+
on: sinon.stub(),
121+
username: null,
122+
userPrivateKey: null,
123+
};
124+
});
125+
126+
it('should set up client event handlers', () => {
127+
server.handleClient(mockClient);
128+
expect(mockClient.on.calledWith('error')).to.be.true;
129+
expect(mockClient.on.calledWith('end')).to.be.true;
130+
expect(mockClient.on.calledWith('close')).to.be.true;
131+
expect(mockClient.on.calledWith('global request')).to.be.true;
132+
expect(mockClient.on.calledWith('ready')).to.be.true;
133+
expect(mockClient.on.calledWith('authentication')).to.be.true;
134+
});
135+
136+
describe('authentication', () => {
137+
it('should handle public key authentication successfully', async () => {
138+
const mockCtx = {
139+
method: 'publickey',
140+
key: {
141+
algo: 'ssh-rsa',
142+
data: Buffer.from('mock-key-data'),
143+
comment: 'test-key',
144+
},
145+
accept: sinon.stub(),
146+
reject: sinon.stub(),
147+
};
148+
149+
mockDb.findUserBySSHKey.resolves({ username: 'test-user' });
150+
151+
server.handleClient(mockClient);
152+
const authHandler = mockClient.on.withArgs('authentication').firstCall.args[1];
153+
await authHandler(mockCtx);
154+
155+
expect(mockDb.findUserBySSHKey.calledOnce).to.be.true;
156+
expect(mockCtx.accept.calledOnce).to.be.true;
157+
expect(mockClient.username).to.equal('test-user');
158+
expect(mockClient.userPrivateKey).to.deep.equal(mockCtx.key);
159+
});
160+
161+
it('should handle password authentication successfully', async () => {
162+
const mockCtx = {
163+
method: 'password',
164+
username: 'test-user',
165+
password: 'test-password',
166+
accept: sinon.stub(),
167+
reject: sinon.stub(),
168+
};
169+
170+
mockDb.findUser.resolves({
171+
username: 'test-user',
172+
password: '$2a$10$mockHash',
173+
});
174+
175+
const bcrypt = require('bcryptjs');
176+
sinon.stub(bcrypt, 'compare').resolves(true);
177+
178+
server.handleClient(mockClient);
179+
const authHandler = mockClient.on.withArgs('authentication').firstCall.args[1];
180+
await authHandler(mockCtx);
181+
182+
expect(mockDb.findUser.calledWith('test-user')).to.be.true;
183+
expect(bcrypt.compare.calledWith('test-password', '$2a$10$mockHash')).to.be.true;
184+
expect(mockCtx.accept.calledOnce).to.be.true;
185+
});
186+
});
187+
});
188+
189+
describe('handleSession', () => {
190+
let mockSession;
191+
let mockStream;
192+
let mockAccept;
193+
let mockReject;
194+
195+
beforeEach(() => {
196+
mockStream = {
197+
write: sinon.stub(),
198+
end: sinon.stub(),
199+
exit: sinon.stub(),
200+
on: sinon.stub(),
201+
};
202+
203+
mockSession = {
204+
on: sinon.stub(),
205+
_channel: {
206+
_client: {
207+
userPrivateKey: null,
208+
},
209+
},
210+
};
211+
212+
mockAccept = sinon.stub().returns(mockSession);
213+
mockReject = sinon.stub();
214+
});
215+
216+
it('should handle git-upload-pack command', async () => {
217+
const mockInfo = {
218+
command: "git-upload-pack 'test/repo'",
219+
};
220+
221+
mockChain.executeChain.resolves({
222+
error: false,
223+
blocked: false,
224+
});
225+
226+
const { Client } = require('ssh2');
227+
const mockSsh2Client = {
228+
on: sinon.stub(),
229+
connect: sinon.stub(),
230+
exec: sinon.stub(),
231+
};
232+
233+
// Mock the SSH client constructor
234+
sinon.stub(Client.prototype, 'on').callsFake(mockSsh2Client.on);
235+
sinon.stub(Client.prototype, 'connect').callsFake(mockSsh2Client.connect);
236+
sinon.stub(Client.prototype, 'exec').callsFake(mockSsh2Client.exec);
237+
238+
// Mock the ready event
239+
mockSsh2Client.on.withArgs('ready').callsFake((event, callback) => {
240+
callback();
241+
});
242+
243+
// Mock the exec response
244+
mockSsh2Client.exec.callsFake((command, options, callback) => {
245+
const mockStream = {
246+
on: sinon.stub(),
247+
write: sinon.stub(),
248+
end: sinon.stub(),
249+
};
250+
callback(null, mockStream);
251+
});
252+
253+
server.handleSession(mockAccept, mockReject);
254+
const execHandler = mockSession.on.withArgs('exec').firstCall.args[1];
255+
await execHandler(mockAccept, mockReject, mockInfo);
256+
257+
expect(
258+
mockChain.executeChain.calledWith({
259+
method: 'GET',
260+
originalUrl: " 'test/repo",
261+
isSSH: true,
262+
headers: {
263+
'user-agent': 'git/2.0.0',
264+
'content-type': undefined,
265+
},
266+
}),
267+
).to.be.true;
268+
});
269+
270+
it('should handle git-receive-pack command', async () => {
271+
const mockInfo = {
272+
command: "git-receive-pack 'test/repo'",
273+
};
274+
275+
mockChain.executeChain.resolves({
276+
error: false,
277+
blocked: false,
278+
});
279+
280+
const { Client } = require('ssh2');
281+
const mockSsh2Client = {
282+
on: sinon.stub(),
283+
connect: sinon.stub(),
284+
exec: sinon.stub(),
285+
};
286+
sinon.stub(Client.prototype, 'on').callsFake(mockSsh2Client.on);
287+
sinon.stub(Client.prototype, 'connect').callsFake(mockSsh2Client.connect);
288+
sinon.stub(Client.prototype, 'exec').callsFake(mockSsh2Client.exec);
289+
290+
server.handleSession(mockAccept, mockReject);
291+
const execHandler = mockSession.on.withArgs('exec').firstCall.args[1];
292+
await execHandler(mockAccept, mockReject, mockInfo);
293+
294+
expect(
295+
mockChain.executeChain.calledWith({
296+
method: 'POST',
297+
originalUrl: " 'test/repo",
298+
isSSH: true,
299+
headers: {
300+
'user-agent': 'git/2.0.0',
301+
'content-type': 'application/x-git-receive-pack-request',
302+
},
303+
}),
304+
).to.be.true;
305+
});
306+
307+
it('should handle unsupported commands', async () => {
308+
const mockInfo = {
309+
command: 'unsupported-command',
310+
};
311+
312+
// Mock the stream that accept() returns
313+
mockStream = {
314+
write: sinon.stub(),
315+
end: sinon.stub(),
316+
};
317+
318+
// Mock the session
319+
const mockSession = {
320+
on: sinon.stub(),
321+
};
322+
323+
// Set up the exec handler
324+
mockSession.on.withArgs('exec').callsFake((event, handler) => {
325+
// First accept call returns the session
326+
// const sessionAccept = () => mockSession;
327+
// Second accept call returns the stream
328+
const streamAccept = () => mockStream;
329+
handler(streamAccept, mockReject, mockInfo);
330+
});
331+
332+
// Update mockAccept to return our mock session
333+
mockAccept = sinon.stub().returns(mockSession);
334+
335+
server.handleSession(mockAccept, mockReject);
336+
337+
expect(mockStream.write.calledWith('Unsupported command')).to.be.true;
338+
expect(mockStream.end.calledOnce).to.be.true;
339+
});
340+
});
341+
});

0 commit comments

Comments
 (0)