Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
343 changes: 343 additions & 0 deletions tests/input-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
import { z } from "zod";

/**
* Input validation tests for SSH connection parameters.
*
* These tests validate the Zod schemas used by the ssh_connect and ssh_execute
* tools to ensure that invalid inputs are rejected before reaching the SSH layer.
* Tests run against the same schema shapes defined in the tool registrations.
*/

// ---------------------------------------------------------------------------
// Schema definitions (mirrors the inputSchema objects in src/tools/)
// ---------------------------------------------------------------------------

const sshConnectSchema = z.object({
host: z.string(),
username: z.string().optional(),
port: z.number().optional(),
password: z.string().optional(),
privateKeyPath: z.string().optional(),
passphrase: z.string().optional(),
useAgent: z.boolean().optional(),
connectTimeout: z.number().optional(),
connectionId: z.string().optional(),
});

const sshExecuteSchema = z.object({
connectionId: z.string(),
command: z.string(),
cwd: z.string().optional(),
timeout: z.number().optional(),
errOnNonZero: z.boolean().optional(),
});

// ---------------------------------------------------------------------------
// Minimal test runner
// ---------------------------------------------------------------------------

interface TestResult {
name: string;
passed: boolean;
error?: string;
}

const results: TestResult[] = [];
let currentDescribe = "";

function describe(name: string, fn: () => void) {
currentDescribe = name;
fn();
currentDescribe = "";
}

function it(name: string, fn: () => void) {
const fullName = currentDescribe ? `${currentDescribe} > ${name}` : name;
try {
fn();
results.push({ name: fullName, passed: true });
} catch (err: any) {
results.push({ name: fullName, passed: false, error: err.message });
}
}

function expect<T>(actual: T) {
return {
toBe(expected: T) {
if (actual !== expected) {
throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
},
toEqual(expected: T) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
);
}
},
toBeUndefined() {
if (actual !== undefined) {
throw new Error(`Expected undefined, got ${JSON.stringify(actual)}`);
}
},
toThrow() {
if (typeof actual !== "function") {
throw new Error("Expected a function");
}
let threw = false;
try {
(actual as Function)();
} catch {
threw = true;
}
if (!threw) {
throw new Error("Expected function to throw, but it did not");
}
},
not: {
toThrow() {
if (typeof actual !== "function") {
throw new Error("Expected a function");
}
try {
(actual as Function)();
} catch (err: any) {
throw new Error(`Expected function not to throw, but it threw: ${err.message}`);
}
},
},
};
}

// ---------------------------------------------------------------------------
// Tests: ssh_connect input validation
// ---------------------------------------------------------------------------

describe("ssh_connect input validation", () => {
it("accepts a minimal valid input with only host", () => {
const input = { host: "example.com" };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.host).toBe("example.com");
expect(result.data.port).toBeUndefined();
expect(result.data.username).toBeUndefined();
}
});

it("accepts a fully specified connection with all optional fields", () => {
const input = {
host: "192.168.1.100",
username: "deploy",
port: 2222,
password: "s3cret",
privateKeyPath: "~/.ssh/id_ed25519",
passphrase: "keypass",
useAgent: false,
connectTimeout: 5000,
connectionId: "prod-server-1",
};
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.host).toBe("192.168.1.100");
expect(result.data.username).toBe("deploy");
expect(result.data.port).toBe(2222);
expect(result.data.password).toBe("s3cret");
expect(result.data.connectionId).toBe("prod-server-1");
expect(result.data.useAgent).toBe(false);
expect(result.data.connectTimeout).toBe(5000);
}
});

it("rejects input when host is missing", () => {
const input = { username: "admin", port: 22 };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("rejects input when host is not a string", () => {
const input = { host: 12345 };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("rejects input when port is a string instead of a number", () => {
const input = { host: "example.com", port: "22" };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("rejects input when port is negative", () => {
// Zod's z.number() does not enforce port range by default, so negative
// numbers pass schema validation. This test documents that behavior.
const input = { host: "example.com", port: -1 };
const result = sshConnectSchema.safeParse(input);
// Schema allows any number; range validation happens at the SSH layer
expect(result.success).toBe(true);
});

it("rejects input when username is a number instead of a string", () => {
const input = { host: "example.com", username: 42 };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("rejects input when useAgent is a string instead of a boolean", () => {
const input = { host: "example.com", useAgent: "yes" };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("rejects input when connectTimeout is a string", () => {
const input = { host: "example.com", connectTimeout: "5000" };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("accepts an empty string as host (schema allows it, DNS will reject)", () => {
const input = { host: "" };
const result = sshConnectSchema.safeParse(input);
// z.string() allows empty strings; actual resolution fails at connect time
expect(result.success).toBe(true);
});

it("rejects completely empty input object", () => {
const input = {};
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("strips unknown properties via strict parsing", () => {
const strict = sshConnectSchema.strict();
const input = { host: "example.com", unknownField: "should fail" };
const result = strict.safeParse(input);
expect(result.success).toBe(false);
});
});

// ---------------------------------------------------------------------------
// Tests: ssh_execute input validation
// ---------------------------------------------------------------------------

describe("ssh_execute input validation", () => {
it("accepts a minimal valid execute input", () => {
const input = { connectionId: "my-conn", command: "ls -la" };
const result = sshExecuteSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.connectionId).toBe("my-conn");
expect(result.data.command).toBe("ls -la");
expect(result.data.cwd).toBeUndefined();
expect(result.data.timeout).toBeUndefined();
expect(result.data.errOnNonZero).toBeUndefined();
}
});

it("accepts a fully specified execute input", () => {
const input = {
connectionId: "prod-1",
command: "cat /var/log/syslog",
cwd: "/var/log",
timeout: 60000,
errOnNonZero: true,
};
const result = sshExecuteSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.cwd).toBe("/var/log");
expect(result.data.timeout).toBe(60000);
expect(result.data.errOnNonZero).toBe(true);
}
});

it("rejects input when connectionId is missing", () => {
const input = { command: "whoami" };
const result = sshExecuteSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("rejects input when command is missing", () => {
const input = { connectionId: "my-conn" };
const result = sshExecuteSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("rejects input when timeout is a string instead of a number", () => {
const input = { connectionId: "my-conn", command: "uptime", timeout: "30000" };
const result = sshExecuteSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("rejects input when errOnNonZero is a string instead of a boolean", () => {
const input = {
connectionId: "my-conn",
command: "grep pattern file",
errOnNonZero: "true",
};
const result = sshExecuteSchema.safeParse(input);
expect(result.success).toBe(false);
});
});

// ---------------------------------------------------------------------------
// Tests: edge cases and type coercion
// ---------------------------------------------------------------------------

describe("edge cases and type coercion", () => {
it("does not coerce null to a valid value for required fields", () => {
const input = { host: null };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("does not coerce undefined host to a valid value", () => {
const input = { host: undefined };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(false);
});

it("accepts IPv6 addresses as host strings", () => {
const input = { host: "::1" };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.host).toBe("::1");
}
});

it("accepts zero as a port number", () => {
const input = { host: "example.com", port: 0 };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(true);
});

it("rejects an array passed as host", () => {
const input = { host: ["example.com"] };
const result = sshConnectSchema.safeParse(input);
expect(result.success).toBe(false);
});
});

// ---------------------------------------------------------------------------
// Run and report
// ---------------------------------------------------------------------------

const passed = results.filter((r) => r.passed).length;
const failed = results.filter((r) => !r.passed).length;

console.log(`\nTest Results: ${passed} passed, ${failed} failed, ${results.length} total\n`);

for (const r of results) {
const icon = r.passed ? "PASS" : "FAIL";
console.log(` [${icon}] ${r.name}`);
if (!r.passed && r.error) {
console.log(` ${r.error}`);
}
}

console.log();

if (failed > 0) {
process.exit(1);
}