diff --git a/tests/config-parser.test.ts b/tests/config-parser.test.ts new file mode 100644 index 0000000..7183c28 --- /dev/null +++ b/tests/config-parser.test.ts @@ -0,0 +1,255 @@ +/** + * Tests for SSH config parsing logic in src/config.ts + * + * These tests verify that buildConnectConfig() correctly resolves + * SSH connection parameters from explicit params, ~/.ssh/config, + * and environment variables, following the documented auth priority: + * explicit password > explicit key > SSH config IdentityFile > SSH agent + * + * Since the module reads real filesystem paths and environment variables, + * tests use dependency injection via module-level mocks where possible + * and document expected behavior for manual/integration verification. + */ + +import { strict as assert } from "node:assert"; +import { describe, it, beforeEach, afterEach } from "node:test"; +import { homedir } from "node:os"; +import { resolve as pathResolve } from "node:path"; +import { expandTilde, buildConnectConfig } from "../src/config.js"; + +// --------------------------------------------------------------------------- +// 1. expandTilde utility +// --------------------------------------------------------------------------- +describe("expandTilde", () => { + it("should expand bare tilde to home directory", () => { + const result = expandTilde("~"); + assert.equal(result, homedir()); + }); + + it("should expand tilde-slash prefix to home-relative path", () => { + const result = expandTilde("~/.ssh/id_rsa"); + assert.equal(result, pathResolve(homedir(), ".ssh/id_rsa")); + }); + + it("should leave absolute paths unchanged", () => { + const abs = "/etc/ssh/ssh_host_rsa_key"; + assert.equal(expandTilde(abs), abs); + }); + + it("should leave relative paths without tilde unchanged", () => { + const rel = "keys/my_key"; + assert.equal(expandTilde(rel), rel); + }); + + it("should not expand tilde in the middle of a path", () => { + const mid = "/home/user/~/.ssh/id_rsa"; + assert.equal(expandTilde(mid), mid); + }); +}); + +// --------------------------------------------------------------------------- +// 2. buildConnectConfig — default values +// --------------------------------------------------------------------------- +describe("buildConnectConfig defaults", () => { + const savedSSHAuthSock = process.env.SSH_AUTH_SOCK; + + beforeEach(() => { + // Remove agent so we can test the no-auth fallback path + delete process.env.SSH_AUTH_SOCK; + }); + + afterEach(() => { + if (savedSSHAuthSock !== undefined) { + process.env.SSH_AUTH_SOCK = savedSSHAuthSock; + } else { + delete process.env.SSH_AUTH_SOCK; + } + }); + + it("should use port 22 when no port is specified and SSH config has none", () => { + const config = buildConnectConfig({ host: "nonexistent.test.invalid" }); + assert.equal(config.port, 22); + }); + + it("should fall back to process.env.USER or 'root' for username", () => { + const config = buildConnectConfig({ host: "nonexistent.test.invalid" }); + const expected = process.env.USER || "root"; + assert.equal(config.username, expected); + }); + + it("should set keepaliveInterval to 10000", () => { + const config = buildConnectConfig({ host: "nonexistent.test.invalid" }); + assert.equal(config.keepaliveInterval, 10000); + }); + + it("should set keepaliveCountMax to 3", () => { + const config = buildConnectConfig({ host: "nonexistent.test.invalid" }); + assert.equal(config.keepaliveCountMax, 3); + }); + + it("should set readyTimeout to 20000 by default", () => { + const config = buildConnectConfig({ host: "nonexistent.test.invalid" }); + assert.equal(config.readyTimeout, 20000); + }); +}); + +// --------------------------------------------------------------------------- +// 3. buildConnectConfig — explicit parameter overrides +// --------------------------------------------------------------------------- +describe("buildConnectConfig explicit params", () => { + const savedSSHAuthSock = process.env.SSH_AUTH_SOCK; + + beforeEach(() => { + delete process.env.SSH_AUTH_SOCK; + }); + + afterEach(() => { + if (savedSSHAuthSock !== undefined) { + process.env.SSH_AUTH_SOCK = savedSSHAuthSock; + } else { + delete process.env.SSH_AUTH_SOCK; + } + }); + + it("should use explicit port when provided", () => { + const config = buildConnectConfig({ + host: "nonexistent.test.invalid", + port: 2222, + }); + assert.equal(config.port, 2222); + }); + + it("should use explicit username when provided", () => { + const config = buildConnectConfig({ + host: "nonexistent.test.invalid", + username: "deploy", + }); + assert.equal(config.username, "deploy"); + }); + + it("should use explicit connectTimeout as readyTimeout", () => { + const config = buildConnectConfig({ + host: "nonexistent.test.invalid", + connectTimeout: 5000, + }); + assert.equal(config.readyTimeout, 5000); + }); +}); + +// --------------------------------------------------------------------------- +// 4. buildConnectConfig — auth priority: password takes precedence +// --------------------------------------------------------------------------- +describe("buildConnectConfig auth priority", () => { + const savedSSHAuthSock = process.env.SSH_AUTH_SOCK; + + beforeEach(() => { + // Set agent socket to prove password still wins + process.env.SSH_AUTH_SOCK = "/tmp/fake-agent.sock"; + }); + + afterEach(() => { + if (savedSSHAuthSock !== undefined) { + process.env.SSH_AUTH_SOCK = savedSSHAuthSock; + } else { + delete process.env.SSH_AUTH_SOCK; + } + }); + + it("should set password and NOT set agent when password is provided", () => { + const config = buildConnectConfig({ + host: "nonexistent.test.invalid", + password: "s3cret", + }); + assert.equal(config.password, "s3cret"); + // When password is set, agent should not be configured + assert.equal(config.agent, undefined); + assert.equal(config.privateKey, undefined); + }); + + it("should prefer explicit privateKeyPath over agent when key file exists", () => { + // This test requires a real key file. We use a path that is very + // likely NOT to exist to confirm it throws (proving the code path + // attempts to read the file). A real integration test would supply + // an actual key. + assert.throws( + () => + buildConnectConfig({ + host: "nonexistent.test.invalid", + privateKeyPath: "/tmp/__nonexistent_key_for_test__", + }), + { code: "ENOENT" }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// 5. buildConnectConfig — SSH agent fallback +// --------------------------------------------------------------------------- +describe("buildConnectConfig SSH agent fallback", () => { + const savedSSHAuthSock = process.env.SSH_AUTH_SOCK; + + afterEach(() => { + if (savedSSHAuthSock !== undefined) { + process.env.SSH_AUTH_SOCK = savedSSHAuthSock; + } else { + delete process.env.SSH_AUTH_SOCK; + } + }); + + it("should set agent to SSH_AUTH_SOCK when no password or key is given", () => { + process.env.SSH_AUTH_SOCK = "/tmp/fake-agent.sock"; + const config = buildConnectConfig({ host: "nonexistent.test.invalid" }); + // If there are no IdentityFile entries in SSH config for this host, + // the fallback should set config.agent + if (!config.authHandler) { + assert.equal(config.agent, "/tmp/fake-agent.sock"); + } + // If authHandler is set, it means IdentityFile entries were found in + // the real SSH config and the multi-key path is active, which still + // includes agent as a fallback — that is also valid behavior. + }); + + it("should NOT set agent when SSH_AUTH_SOCK is unset", () => { + delete process.env.SSH_AUTH_SOCK; + const config = buildConnectConfig({ host: "nonexistent.test.invalid" }); + assert.equal(config.agent, undefined); + // authHandler may still be set if SSH config has IdentityFile entries + }); + + it("should NOT set agent when useAgent is explicitly false", () => { + process.env.SSH_AUTH_SOCK = "/tmp/fake-agent.sock"; + const config = buildConnectConfig({ + host: "nonexistent.test.invalid", + useAgent: false, + }); + // Agent must not be configured + assert.equal(config.agent, undefined); + }); +}); + +// --------------------------------------------------------------------------- +// 6. buildConnectConfig — host resolution from SSH config +// --------------------------------------------------------------------------- +describe("buildConnectConfig host resolution", () => { + const savedSSHAuthSock = process.env.SSH_AUTH_SOCK; + + beforeEach(() => { + delete process.env.SSH_AUTH_SOCK; + }); + + afterEach(() => { + if (savedSSHAuthSock !== undefined) { + process.env.SSH_AUTH_SOCK = savedSSHAuthSock; + } else { + delete process.env.SSH_AUTH_SOCK; + } + }); + + it("should pass through the original host when SSH config has no HostName alias", () => { + // Using a hostname that is very unlikely to appear in any local SSH config + const config = buildConnectConfig({ + host: "zzz-unlikely-host-alias-42.test.invalid", + }); + assert.equal(config.host, "zzz-unlikely-host-alias-42.test.invalid"); + }); +});