diff --git a/tests/connection-timeout.test.ts b/tests/connection-timeout.test.ts new file mode 100644 index 0000000..ba2a6ed --- /dev/null +++ b/tests/connection-timeout.test.ts @@ -0,0 +1,367 @@ +/** + * Connection Timeout Tests + * + * Tests verifying timeout behavior for SSH connections and command execution + * in the ssh-mcp-server. These tests use mock SSH2 clients to simulate + * timeout scenarios without requiring real SSH infrastructure. + */ + +import { strict as assert } from "node:assert"; +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import { EventEmitter } from "node:events"; +import { connections, StoredConnection } from "../src/connections.js"; +import type { ConnectConfig } from "ssh2"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Minimal mock that behaves like an ssh2 Client for event-driven tests. */ +function createMockClient(): EventEmitter & { + connect: (cfg: ConnectConfig) => void; + end: () => void; + destroy: () => void; + exec: (cmd: string, cb: (err: Error | null, stream: EventEmitter | null) => void) => void; +} { + const emitter = new EventEmitter() as any; + emitter.connect = mock.fn((_cfg: ConnectConfig) => {}); + emitter.end = mock.fn(() => {}); + emitter.destroy = mock.fn(() => {}); + emitter.exec = mock.fn( + (_cmd: string, cb: (err: Error | null, stream: EventEmitter | null) => void) => { + cb(null, createMockStream()); + } + ); + return emitter; +} + +/** Minimal mock stream returned by client.exec(). */ +function createMockStream(): EventEmitter & { destroy: () => void; stderr: EventEmitter } { + const stream = new EventEmitter() as any; + stream.destroy = mock.fn(() => {}); + stream.stderr = new EventEmitter(); + return stream; +} + +function makeStoredConnection( + overrides: Partial = {} +): StoredConnection { + const client = createMockClient() as any; + return { + client, + connectConfig: { host: "test-host", port: 22, username: "user" }, + ready: true, + host: "test-host", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Test Suite +// --------------------------------------------------------------------------- + +describe("Connection Timeout Behavior", () => { + beforeEach(() => { + connections.clear(); + }); + + afterEach(() => { + connections.clear(); + }); + + // ----------------------------------------------------------------------- + // 1. Outer connection timeout fires when the server never responds + // ----------------------------------------------------------------------- + it("should timeout when the SSH server never becomes ready", async () => { + // Simulate the outer-timeout logic from connect.ts: + // If neither "ready" nor "error" fires before outerTimeout, the client + // is destroyed and an error result is produced. + const client = createMockClient(); + const connectTimeoutMs = 100; // very short for testing + const outerTimeout = connectTimeoutMs + 50; // mirrors (readyTimeout + 5s) logic + + const result = await new Promise<{ text: string; isError: boolean }>((resolve) => { + let settled = false; + + const finish = (text: string, isError: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ text, isError }); + }; + + const timer = setTimeout(() => { + client.destroy(); + finish( + `Connection timed out after ${outerTimeout}ms. The host may be unreachable or firewalled.`, + true + ); + }, outerTimeout); + + client.on("ready", () => { + finish("Connected", false); + }); + + client.on("error", (err: Error) => { + finish(`SSH connection failed: ${err.message}`, true); + }); + + // Simulate connecting but server never responds (no "ready" event) + client.connect({ host: "unreachable.example.com", port: 22, readyTimeout: connectTimeoutMs }); + }); + + assert.equal(result.isError, true); + assert.match(result.text, /timed out/i); + assert.match(result.text, new RegExp(String(outerTimeout))); + }); + + // ----------------------------------------------------------------------- + // 2. Command execution timeout terminates the command and returns partial output + // ----------------------------------------------------------------------- + it("should timeout a long-running command and return partial output", async () => { + const stored = makeStoredConnection(); + connections.set("timeout-test", stored); + + const commandTimeout = 100; + + const result = await new Promise<{ text: string; isError: boolean }>((resolve) => { + let settled = false; + let timer: ReturnType | null = null; + + const finish = (text: string, isError: boolean) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve({ text, isError }); + }; + + // Replicate execute.ts command-timeout logic + const mockStream = createMockStream(); + let stdout = ""; + + timer = setTimeout(() => { + mockStream.destroy(); + finish( + `Command timed out after ${commandTimeout}ms.\n\nPartial stdout:\n${stdout}\n\nPartial stderr:\n`, + true + ); + }, commandTimeout); + + mockStream.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + // Simulate partial output arriving before timeout + mockStream.emit("data", Buffer.from("partial line 1\n")); + mockStream.emit("data", Buffer.from("partial line 2\n")); + // The command never closes — timeout should fire + }); + + assert.equal(result.isError, true); + assert.match(result.text, /timed out/i); + assert.match(result.text, /partial line 1/); + assert.match(result.text, /partial line 2/); + }); + + // ----------------------------------------------------------------------- + // 3. readyTimeout in ConnectConfig is derived from connectTimeout param + // ----------------------------------------------------------------------- + it("should set readyTimeout from the connectTimeout parameter", () => { + // The buildConnectConfig function sets readyTimeout = connectTimeout ?? 20000 + // and the outer timeout is readyTimeout + 5000. + const defaultReadyTimeout = 20000; + const defaultOuterTimeout = defaultReadyTimeout + 5000; + + assert.equal(defaultOuterTimeout, 25000, "default outer timeout should be 25000ms"); + + const customConnectTimeout = 5000; + const customOuterTimeout = customConnectTimeout + 5000; + + assert.equal(customOuterTimeout, 10000, "custom outer timeout should be connectTimeout + 5000ms"); + + // Verify the relationship: outerTimeout always exceeds readyTimeout + assert.ok( + customOuterTimeout > customConnectTimeout, + "outer timeout must exceed readyTimeout to give ssh2 a chance to fire its own timeout" + ); + }); + + // ----------------------------------------------------------------------- + // 4. Graceful timeout handling: settled flag prevents double resolution + // ----------------------------------------------------------------------- + it("should not resolve twice when both error and timeout fire", async () => { + const client = createMockClient(); + let resolveCount = 0; + + await new Promise((resolve) => { + let settled = false; + + const finish = (_text: string, _isError: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolveCount++; + resolve(); + }; + + const timer = setTimeout(() => { + client.destroy(); + finish("Timed out", true); + }, 50); + + client.on("error", (err: Error) => { + finish(`Error: ${err.message}`, true); + }); + + // Fire error first, then let the timeout also fire + client.emit("error", new Error("ECONNREFUSED")); + }); + + // Even though timeout could still fire, settled flag should prevent a second resolution + // Wait beyond the timeout to be sure + await new Promise((r) => setTimeout(r, 100)); + + assert.equal(resolveCount, 1, "finish() should only resolve once due to settled guard"); + }); + + // ----------------------------------------------------------------------- + // 5. Timeout fires after "ready" event race: connection becomes ready just in time + // ----------------------------------------------------------------------- + it("should succeed when ready fires before the outer timeout", async () => { + const client = createMockClient(); + const outerTimeout = 200; + + const result = await new Promise<{ text: string; isError: boolean }>((resolve) => { + let settled = false; + + const finish = (text: string, isError: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ text, isError }); + }; + + const timer = setTimeout(() => { + client.destroy(); + finish("Connection timed out", true); + }, outerTimeout); + + client.on("ready", () => { + finish("Connected successfully", false); + }); + + client.connect({ host: "fast-host.example.com", port: 22 }); + + // Simulate ready firing after 50ms (well within the 200ms timeout) + setTimeout(() => { + client.emit("ready"); + }, 50); + }); + + assert.equal(result.isError, false); + assert.match(result.text, /Connected successfully/); + }); + + // ----------------------------------------------------------------------- + // 6. Broken-pipe detection: stored.ready is false after close/end events + // ----------------------------------------------------------------------- + it("should mark connection as not ready after close or end events", () => { + const stored = makeStoredConnection({ ready: true }); + connections.set("pipe-test", stored); + + assert.equal(stored.ready, true, "connection starts as ready"); + + // Simulate what connect.ts does: listen for close/end to set ready = false + stored.client.on("close", () => { + stored.ready = false; + }); + stored.client.on("end", () => { + stored.ready = false; + }); + + // Emit close event + (stored.client as EventEmitter).emit("close"); + assert.equal(stored.ready, false, "ready should be false after close event"); + + // Reset and test end event + stored.ready = true; + (stored.client as EventEmitter).emit("end"); + assert.equal(stored.ready, false, "ready should be false after end event"); + }); + + // ----------------------------------------------------------------------- + // 7. Executing on a not-ready connection returns an error immediately + // ----------------------------------------------------------------------- + it("should return an error when executing on a stale connection", () => { + const stored = makeStoredConnection({ ready: false }); + connections.set("stale-conn", stored); + + // Replicate the guard from execute.ts + const conn = connections.get("stale-conn")!; + assert.ok(conn, "connection should exist in map"); + assert.equal(conn.ready, false); + + // The execute tool checks `stored.ready` and returns an error without + // attempting to run the command. + if (!conn.ready) { + connections.delete("stale-conn"); + conn.client.end(); + } + + assert.equal( + connections.has("stale-conn"), + false, + "stale connection should be removed from the map" + ); + }); + + // ----------------------------------------------------------------------- + // 8. Default timeout values are correct + // ----------------------------------------------------------------------- + it("should use correct default timeout values", () => { + // Default connectTimeout (readyTimeout) is 20000ms + const defaultConnectTimeout = 20000; + // Default command timeout is 30000ms + const defaultCommandTimeout = 30000; + // Outer timeout is readyTimeout + 5000ms + const defaultOuterTimeout = defaultConnectTimeout + 5000; + + assert.equal(defaultConnectTimeout, 20000, "default connect timeout should be 20s"); + assert.equal(defaultCommandTimeout, 30000, "default command timeout should be 30s"); + assert.equal(defaultOuterTimeout, 25000, "default outer timeout should be 25s"); + + // Verify keepalive settings from config.ts + const keepaliveInterval = 10000; + const keepaliveCountMax = 3; + // Total keepalive tolerance = interval * countMax = 30s + const keepaliveTolerance = keepaliveInterval * keepaliveCountMax; + assert.equal(keepaliveTolerance, 30000, "keepalive tolerance should be 30s"); + }); + + // ----------------------------------------------------------------------- + // 9. Existing connection is replaced on reconnect (timeout-related cleanup) + // ----------------------------------------------------------------------- + it("should close and replace an existing connection when reconnecting", () => { + const oldClient = createMockClient(); + const oldStored = makeStoredConnection({ client: oldClient as any }); + connections.set("reuse-id", oldStored); + + assert.equal(connections.size, 1); + + // Replicate connect.ts: close existing, then create new + const existing = connections.get("reuse-id"); + if (existing) { + existing.client.end(); + connections.delete("reuse-id"); + } + + assert.equal(connections.size, 0, "old connection should be removed"); + assert.equal((oldClient.end as any).mock.callCount(), 1, "old client.end() should have been called"); + + // Add the new connection + const newStored = makeStoredConnection(); + connections.set("reuse-id", newStored); + assert.equal(connections.size, 1, "new connection should be in the map"); + assert.notEqual(connections.get("reuse-id"), oldStored, "should be a different connection object"); + }); +});