@aaarc/handfree-ssh-mcp 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,486 +0,0 @@
1
- /**
2
- * Tools Unit Tests
3
- *
4
- * Tests for list-servers, upload, download, show-whitelist tools
5
- * Uses mock SSHConnectionManager to avoid actual SSH connections.
6
- */
7
- import { describe, it, beforeEach } from "node:test";
8
- import assert from "node:assert";
9
- /**
10
- * Mock SSHConnectionManager for testing tools without real SSH connections
11
- */
12
- class MockSSHConnectionManager {
13
- configs = {};
14
- enabledServers = [];
15
- defaultName = "default";
16
- setConfig(configs, enabledServers) {
17
- this.configs = configs;
18
- this.enabledServers = enabledServers || Object.keys(configs);
19
- if (this.enabledServers.length > 0) {
20
- this.defaultName = this.enabledServers[0];
21
- }
22
- }
23
- getServerConfig(name) {
24
- const serverName = name || this.defaultName;
25
- if (!this.enabledServers.includes(serverName)) {
26
- return null;
27
- }
28
- return this.configs[serverName] || null;
29
- }
30
- getAllServerInfos() {
31
- return this.enabledServers.map(name => {
32
- const config = this.configs[name];
33
- return {
34
- name,
35
- host: config?.host || "unknown",
36
- port: config?.port || 22,
37
- username: config?.username || "unknown",
38
- connected: false,
39
- };
40
- });
41
- }
42
- async upload(localPath, remotePath, connectionName) {
43
- const serverName = connectionName || this.defaultName;
44
- if (!this.enabledServers.includes(serverName)) {
45
- throw new Error(`Server '${serverName}' not enabled`);
46
- }
47
- // Mock implementation - just return success message
48
- return `Uploaded ${localPath} to ${remotePath} on ${serverName}`;
49
- }
50
- async download(remotePath, localPath, connectionName) {
51
- const serverName = connectionName || this.defaultName;
52
- if (!this.enabledServers.includes(serverName)) {
53
- throw new Error(`Server '${serverName}' not enabled`);
54
- }
55
- // Mock implementation - just return success message
56
- return `Downloaded ${remotePath} to ${localPath} from ${serverName}`;
57
- }
58
- async executeCommand(command, connectionName, options) {
59
- const serverName = connectionName || this.defaultName;
60
- if (!this.enabledServers.includes(serverName)) {
61
- throw new Error(`Server '${serverName}' not enabled`);
62
- }
63
- // Mock implementation - simulate command execution
64
- if (command === "pwd")
65
- return "/home/testuser";
66
- if (command.startsWith("ls"))
67
- return "file1.txt\nfile2.txt\ndir1";
68
- if (command === "hostname")
69
- return "test-server";
70
- if (command === "whoami")
71
- return "testuser";
72
- if (command === "date")
73
- return "Sat Jan 24 12:00:00 UTC 2026";
74
- return `Executed: ${command}`;
75
- }
76
- async executeCommandWithProgress(command, connectionName, options) {
77
- const serverName = connectionName || this.defaultName;
78
- if (!this.enabledServers.includes(serverName)) {
79
- throw new Error(`Server '${serverName}' not enabled`);
80
- }
81
- // Simulate streaming output
82
- if (options?.onProgress) {
83
- options.onProgress("line 1\n");
84
- options.onProgress("line 2\n");
85
- options.onProgress("line 3\n");
86
- }
87
- return "line 1\nline 2\nline 3\n";
88
- }
89
- disconnect() {
90
- // No-op for mock
91
- }
92
- }
93
- // ============================================
94
- // list-servers Tool Tests
95
- // ============================================
96
- describe("list-servers Tool", () => {
97
- let mockManager;
98
- beforeEach(() => {
99
- mockManager = new MockSSHConnectionManager();
100
- });
101
- it("should return empty array when no servers configured", () => {
102
- mockManager.setConfig({}, []);
103
- const result = mockManager.getAllServerInfos();
104
- assert.deepStrictEqual(result, []);
105
- });
106
- it("should return single server info", () => {
107
- mockManager.setConfig({
108
- dev: {
109
- host: "192.168.1.1",
110
- port: 22,
111
- username: "testuser",
112
- password: "testpass",
113
- }
114
- }, ["dev"]);
115
- const result = mockManager.getAllServerInfos();
116
- assert.strictEqual(result.length, 1);
117
- assert.strictEqual(result[0].name, "dev");
118
- assert.strictEqual(result[0].host, "192.168.1.1");
119
- assert.strictEqual(result[0].port, 22);
120
- assert.strictEqual(result[0].username, "testuser");
121
- assert.strictEqual(result[0].connected, false);
122
- });
123
- it("should return multiple servers info", () => {
124
- mockManager.setConfig({
125
- dev: { host: "10.0.0.1", port: 22, username: "dev" },
126
- prod: { host: "10.0.0.2", port: 2222, username: "prod" },
127
- staging: { host: "10.0.0.3", port: 22, username: "staging" },
128
- }, ["dev", "prod", "staging"]);
129
- const result = mockManager.getAllServerInfos();
130
- assert.strictEqual(result.length, 3);
131
- assert.strictEqual(result[0].name, "dev");
132
- assert.strictEqual(result[1].name, "prod");
133
- assert.strictEqual(result[2].name, "staging");
134
- assert.strictEqual(result[1].port, 2222);
135
- });
136
- it("should only return enabled servers", () => {
137
- mockManager.setConfig({
138
- dev: { host: "10.0.0.1", port: 22, username: "dev" },
139
- prod: { host: "10.0.0.2", port: 22, username: "prod" },
140
- staging: { host: "10.0.0.3", port: 22, username: "staging" },
141
- }, ["dev", "staging"]); // prod not enabled
142
- const result = mockManager.getAllServerInfos();
143
- assert.strictEqual(result.length, 2);
144
- const names = result.map(s => s.name);
145
- assert.ok(names.includes("dev"));
146
- assert.ok(names.includes("staging"));
147
- assert.ok(!names.includes("prod"));
148
- });
149
- });
150
- // ============================================
151
- // show-whitelist Tool Tests
152
- // ============================================
153
- describe("show-whitelist Tool", () => {
154
- let mockManager;
155
- beforeEach(() => {
156
- mockManager = new MockSSHConnectionManager();
157
- });
158
- it("should return null for non-existent server", () => {
159
- mockManager.setConfig({
160
- dev: { host: "192.168.1.1", username: "test", password: "test" }
161
- }, ["dev"]);
162
- const result = mockManager.getServerConfig("nonexistent");
163
- assert.strictEqual(result, null);
164
- });
165
- it("should return config for valid server", () => {
166
- const devConfig = {
167
- host: "192.168.1.1",
168
- port: 22,
169
- username: "testuser",
170
- password: "testpass",
171
- commandWhitelist: ["^ls.*$", "^pwd$"],
172
- };
173
- mockManager.setConfig({ dev: devConfig }, ["dev"]);
174
- const result = mockManager.getServerConfig("dev");
175
- assert.ok(result);
176
- assert.strictEqual(result.host, "192.168.1.1");
177
- assert.deepStrictEqual(result.commandWhitelist, ["^ls.*$", "^pwd$"]);
178
- });
179
- it("should return default server config when name not specified", () => {
180
- const devConfig = {
181
- host: "192.168.1.1",
182
- username: "dev",
183
- password: "test",
184
- };
185
- mockManager.setConfig({ dev: devConfig }, ["dev"]);
186
- const result = mockManager.getServerConfig(); // No name - uses default
187
- assert.ok(result);
188
- assert.strictEqual(result.host, "192.168.1.1");
189
- });
190
- it("should return null for disabled server", () => {
191
- mockManager.setConfig({
192
- dev: { host: "10.0.0.1", username: "dev", password: "test" },
193
- prod: { host: "10.0.0.2", username: "prod", password: "test" },
194
- }, ["dev"]); // Only dev enabled
195
- const result = mockManager.getServerConfig("prod");
196
- assert.strictEqual(result, null);
197
- });
198
- it("should return config with blacklist", () => {
199
- const devConfig = {
200
- host: "192.168.1.1",
201
- username: "test",
202
- password: "test",
203
- commandWhitelist: ["^.*$"],
204
- commandBlacklist: ["^rm.*$", "^shutdown.*$"],
205
- };
206
- mockManager.setConfig({ dev: devConfig }, ["dev"]);
207
- const result = mockManager.getServerConfig("dev");
208
- assert.ok(result);
209
- assert.deepStrictEqual(result.commandBlacklist, ["^rm.*$", "^shutdown.*$"]);
210
- });
211
- });
212
- // ============================================
213
- // upload Tool Tests
214
- // ============================================
215
- describe("upload Tool", () => {
216
- let mockManager;
217
- beforeEach(() => {
218
- mockManager = new MockSSHConnectionManager();
219
- mockManager.setConfig({
220
- dev: { host: "192.168.1.1", username: "test", password: "test" }
221
- }, ["dev"]);
222
- });
223
- it("should upload file to default server", async () => {
224
- const result = await mockManager.upload("/local/file.txt", "/remote/file.txt");
225
- assert.ok(result.includes("Uploaded"));
226
- assert.ok(result.includes("/local/file.txt"));
227
- assert.ok(result.includes("/remote/file.txt"));
228
- });
229
- it("should upload file to specified server", async () => {
230
- const result = await mockManager.upload("/local/file.txt", "/remote/file.txt", "dev");
231
- assert.ok(result.includes("dev"));
232
- });
233
- it("should throw error for disabled server", async () => {
234
- mockManager.setConfig({
235
- dev: { host: "192.168.1.1", username: "test", password: "test" },
236
- prod: { host: "192.168.1.2", username: "test", password: "test" },
237
- }, ["dev"]); // Only dev enabled
238
- await assert.rejects(() => mockManager.upload("/local/file.txt", "/remote/file.txt", "prod"), /not enabled/);
239
- });
240
- });
241
- // ============================================
242
- // download Tool Tests
243
- // ============================================
244
- describe("download Tool", () => {
245
- let mockManager;
246
- beforeEach(() => {
247
- mockManager = new MockSSHConnectionManager();
248
- mockManager.setConfig({
249
- dev: { host: "192.168.1.1", username: "test", password: "test" }
250
- }, ["dev"]);
251
- });
252
- it("should download file from default server", async () => {
253
- const result = await mockManager.download("/remote/file.txt", "/local/file.txt");
254
- assert.ok(result.includes("Downloaded"));
255
- assert.ok(result.includes("/remote/file.txt"));
256
- assert.ok(result.includes("/local/file.txt"));
257
- });
258
- it("should download file from specified server", async () => {
259
- const result = await mockManager.download("/remote/file.txt", "/local/file.txt", "dev");
260
- assert.ok(result.includes("dev"));
261
- });
262
- it("should throw error for disabled server", async () => {
263
- mockManager.setConfig({
264
- dev: { host: "192.168.1.1", username: "test", password: "test" },
265
- prod: { host: "192.168.1.2", username: "test", password: "test" },
266
- }, ["dev"]); // Only dev enabled
267
- await assert.rejects(() => mockManager.download("/remote/file.txt", "/local/file.txt", "prod"), /not enabled/);
268
- });
269
- });
270
- // ============================================
271
- // execute-command Streaming Mode Tests
272
- // ============================================
273
- describe("execute-command Streaming Mode", () => {
274
- let mockManager;
275
- beforeEach(() => {
276
- mockManager = new MockSSHConnectionManager();
277
- mockManager.setConfig({
278
- dev: { host: "192.168.1.1", username: "test", password: "test" }
279
- }, ["dev"]);
280
- });
281
- it("should execute command without streaming", async () => {
282
- const result = await mockManager.executeCommand("pwd", "dev");
283
- assert.strictEqual(result, "/home/testuser");
284
- });
285
- it("should execute command with streaming and receive chunks", async () => {
286
- const chunks = [];
287
- const onProgress = (chunk) => chunks.push(chunk);
288
- const result = await mockManager.executeCommandWithProgress("ls -la", "dev", {
289
- onProgress,
290
- });
291
- assert.ok(chunks.length > 0);
292
- assert.ok(result.includes("line 1"));
293
- assert.ok(result.includes("line 2"));
294
- assert.ok(result.includes("line 3"));
295
- });
296
- it("should handle custom timeout", async () => {
297
- const result = await mockManager.executeCommandWithProgress("long-command", "dev", {
298
- timeout: 60000,
299
- });
300
- assert.ok(result);
301
- });
302
- it("should throw error for disabled server in streaming mode", async () => {
303
- mockManager.setConfig({
304
- dev: { host: "192.168.1.1", username: "test", password: "test" },
305
- prod: { host: "192.168.1.2", username: "test", password: "test" },
306
- }, ["dev"]);
307
- await assert.rejects(() => mockManager.executeCommandWithProgress("pwd", "prod"), /not enabled/);
308
- });
309
- });
310
- // ============================================
311
- // Timeout Behavior Tests (Logic Simulation)
312
- // ============================================
313
- /**
314
- * Helper to calculate effective timeout based on stream mode
315
- * Matches the logic in execute-command.ts
316
- */
317
- function calculateTimeout(stream, timeout) {
318
- const useStream = stream !== false;
319
- if (useStream) {
320
- return timeout || 300000; // 5 min default for streaming
321
- }
322
- else {
323
- return timeout || 30000; // 30s default for non-streaming
324
- }
325
- }
326
- describe("Timeout Behavior", () => {
327
- it("should use default 30s timeout for non-streaming mode", () => {
328
- const effectiveTimeout = calculateTimeout(false, undefined);
329
- assert.strictEqual(effectiveTimeout, 30000);
330
- });
331
- it("should use default 300s timeout for streaming mode", () => {
332
- const effectiveTimeout = calculateTimeout(true, undefined);
333
- assert.strictEqual(effectiveTimeout, 300000);
334
- });
335
- it("should use custom timeout when specified in streaming mode", () => {
336
- const effectiveTimeout = calculateTimeout(true, 60000);
337
- assert.strictEqual(effectiveTimeout, 60000);
338
- });
339
- it("should use custom timeout when specified in non-streaming mode", () => {
340
- const effectiveTimeout = calculateTimeout(false, 5000);
341
- assert.strictEqual(effectiveTimeout, 5000);
342
- });
343
- it("should default stream to true when not specified (undefined)", () => {
344
- const effectiveTimeout = calculateTimeout(undefined, undefined);
345
- // undefined !== false is true, so streaming mode is used
346
- assert.strictEqual(effectiveTimeout, 300000);
347
- });
348
- });
349
- // ============================================
350
- // Pattern Helper Function Tests (show-whitelist)
351
- // ============================================
352
- describe("patternToReadable (show-whitelist helper)", () => {
353
- function patternToReadable(pattern) {
354
- let readable = pattern;
355
- readable = readable.replace(/^\^/, "").replace(/\$$/, "");
356
- if (readable === "ls( .*)?")
357
- return "ls, ls -la, ls /path, etc.";
358
- if (readable === "cat .*")
359
- return "cat <file>";
360
- if (readable === "pwd")
361
- return "pwd (current directory)";
362
- if (readable === "whoami")
363
- return "whoami (current user)";
364
- if (readable === "hostname")
365
- return "hostname";
366
- if (readable === "date")
367
- return "date (current time)";
368
- if (readable === "docker ps.*")
369
- return "docker ps, docker ps -a, etc.";
370
- if (readable === "docker logs.*")
371
- return "docker logs <container>";
372
- if (readable === "git .*")
373
- return "git <any subcommand>";
374
- if (readable.includes(".*"))
375
- return readable.replace(/\.\*/g, "<any>");
376
- if (readable.includes(".+"))
377
- return readable.replace(/\.\+/g, "<required>");
378
- return readable;
379
- }
380
- it("should convert ls pattern to readable", () => {
381
- assert.strictEqual(patternToReadable("^ls( .*)?$"), "ls, ls -la, ls /path, etc.");
382
- });
383
- it("should convert cat pattern to readable", () => {
384
- assert.strictEqual(patternToReadable("^cat .*$"), "cat <file>");
385
- });
386
- it("should convert pwd pattern to readable", () => {
387
- assert.strictEqual(patternToReadable("^pwd$"), "pwd (current directory)");
388
- });
389
- it("should convert docker ps pattern to readable", () => {
390
- assert.strictEqual(patternToReadable("^docker ps.*$"), "docker ps, docker ps -a, etc.");
391
- });
392
- it("should convert git pattern to readable", () => {
393
- assert.strictEqual(patternToReadable("^git .*$"), "git <any subcommand>");
394
- });
395
- it("should replace .* with <any> for unknown patterns", () => {
396
- assert.strictEqual(patternToReadable("^custom .*$"), "custom <any>");
397
- });
398
- it("should replace .+ with <required> for unknown patterns", () => {
399
- assert.strictEqual(patternToReadable("^custom .+$"), "custom <required>");
400
- });
401
- });
402
- // ============================================
403
- // Example Generation Tests (show-whitelist)
404
- // ============================================
405
- describe("generateExamples (show-whitelist helper)", () => {
406
- function generateExamples(whitelist) {
407
- const examples = [];
408
- for (const pattern of whitelist) {
409
- if (pattern.includes("^ls"))
410
- examples.push("ls -la");
411
- if (pattern.includes("^cat"))
412
- examples.push("cat /etc/hostname");
413
- if (pattern.includes("^pwd"))
414
- examples.push("pwd");
415
- if (pattern.includes("^whoami"))
416
- examples.push("whoami");
417
- if (pattern.includes("^hostname"))
418
- examples.push("hostname");
419
- if (pattern.includes("^date"))
420
- examples.push("date");
421
- if (pattern.includes("^echo"))
422
- examples.push("echo 'hello world'");
423
- if (pattern.includes("^docker ps"))
424
- examples.push("docker ps -a");
425
- if (pattern.includes("^docker logs"))
426
- examples.push("docker logs --tail 50 <container>");
427
- if (pattern.includes("^git"))
428
- examples.push("git status");
429
- }
430
- return [...new Set(examples)];
431
- }
432
- it("should generate ls example", () => {
433
- const examples = generateExamples(["^ls( .*)?$"]);
434
- assert.ok(examples.includes("ls -la"));
435
- });
436
- it("should generate docker examples", () => {
437
- const examples = generateExamples(["^docker ps.*$", "^docker logs.*$"]);
438
- assert.ok(examples.includes("docker ps -a"));
439
- assert.ok(examples.includes("docker logs --tail 50 <container>"));
440
- });
441
- it("should generate multiple examples for mixed whitelist", () => {
442
- const examples = generateExamples([
443
- "^ls( .*)?$",
444
- "^cat .*$",
445
- "^pwd$",
446
- "^git .*$",
447
- ]);
448
- assert.ok(examples.includes("ls -la"));
449
- assert.ok(examples.includes("cat /etc/hostname"));
450
- assert.ok(examples.includes("pwd"));
451
- assert.ok(examples.includes("git status"));
452
- });
453
- it("should remove duplicates", () => {
454
- const examples = generateExamples(["^ls.*$", "^ls -la$"]); // Both match ls
455
- const lsCount = examples.filter(e => e === "ls -la").length;
456
- assert.strictEqual(lsCount, 1);
457
- });
458
- });
459
- // ============================================
460
- // Server Selection Tests
461
- // ============================================
462
- describe("Server Selection Logic", () => {
463
- let mockManager;
464
- beforeEach(() => {
465
- mockManager = new MockSSHConnectionManager();
466
- });
467
- it("should use first enabled server as default", () => {
468
- mockManager.setConfig({
469
- alpha: { host: "10.0.0.1", username: "a", password: "a" },
470
- beta: { host: "10.0.0.2", username: "b", password: "b" },
471
- }, ["beta", "alpha"]); // beta is first
472
- const config = mockManager.getServerConfig();
473
- assert.ok(config);
474
- assert.strictEqual(config.host, "10.0.0.2"); // beta's host
475
- });
476
- it("should use explicit server name over default", () => {
477
- mockManager.setConfig({
478
- dev: { host: "10.0.0.1", username: "dev", password: "dev" },
479
- prod: { host: "10.0.0.2", username: "prod", password: "prod" },
480
- }, ["dev", "prod"]);
481
- const config = mockManager.getServerConfig("prod");
482
- assert.ok(config);
483
- assert.strictEqual(config.host, "10.0.0.2");
484
- });
485
- });
486
- console.log("\n🧪 Running tools unit tests...\n");