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
255 changes: 255 additions & 0 deletions tests/config-parser.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});