291 lines
8 KiB
JavaScript
291 lines
8 KiB
JavaScript
/* eslint-disable jest/no-conditional-expect */
|
|
// server
|
|
// tests/ssh.test.js
|
|
|
|
const SSH2 = require("ssh2")
|
|
const SSHConnection = require("../app/ssh")
|
|
const { SSHConnectionError } = require("../app/errors")
|
|
const { DEFAULTS } = require("../app/constants")
|
|
|
|
jest.mock("ssh2")
|
|
jest.mock("../app/logger", () => ({
|
|
createNamespacedDebug: jest.fn(() => jest.fn()),
|
|
logError: jest.fn()
|
|
}))
|
|
jest.mock("../app/utils", () => ({
|
|
maskSensitiveData: jest.fn((data) => data)
|
|
}))
|
|
jest.mock("../app/errors", () => ({
|
|
SSHConnectionError: jest.fn(function (message) {
|
|
this.message = message
|
|
}),
|
|
handleError: jest.fn()
|
|
}))
|
|
|
|
describe("SSHConnection", () => {
|
|
let sshConnection
|
|
let mockConfig
|
|
let mockSSH2Client
|
|
let registeredEventHandlers
|
|
|
|
beforeEach(() => {
|
|
registeredEventHandlers = {}
|
|
|
|
mockConfig = {
|
|
ssh: {
|
|
algorithms: {
|
|
kex: ["algo1", "algo2"],
|
|
cipher: ["cipher1", "cipher2"],
|
|
serverHostKey: ["ssh-rsa", "ssh-dss"],
|
|
hmac: ["hmac1", "hmac2"],
|
|
compress: ["none", "zlib"]
|
|
},
|
|
readyTimeout: 20000,
|
|
keepaliveInterval: 60000,
|
|
keepaliveCountMax: 10
|
|
},
|
|
user: {
|
|
name: null,
|
|
password: null,
|
|
privatekey: null
|
|
}
|
|
}
|
|
|
|
mockSSH2Client = {
|
|
on: jest.fn((event, handler) => {
|
|
registeredEventHandlers[event] = handler
|
|
}),
|
|
connect: jest.fn(() => {
|
|
process.nextTick(() => {
|
|
// By default, emit ready event unless test modifies this behavior
|
|
if (registeredEventHandlers.ready) {
|
|
registeredEventHandlers.ready()
|
|
}
|
|
})
|
|
}),
|
|
shell: jest.fn(),
|
|
end: jest.fn()
|
|
}
|
|
|
|
SSH2.Client.mockImplementation(() => mockSSH2Client)
|
|
sshConnection = new SSHConnection(mockConfig)
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks()
|
|
})
|
|
|
|
describe("connect", () => {
|
|
it("should handle immediate connection errors", () => {
|
|
const mockCreds = {
|
|
host: "localhost",
|
|
port: 22,
|
|
username: "user",
|
|
password: "pass"
|
|
}
|
|
|
|
// Mock the connect method to throw an error immediately
|
|
mockSSH2Client.connect.mockImplementation(() => {
|
|
throw new Error("Spooky Error") // Immediate error
|
|
})
|
|
|
|
return sshConnection.connect(mockCreds).catch((error) => {
|
|
expect(error).toBeInstanceOf(SSHConnectionError)
|
|
expect(error.message).toBe("Connection failed: Spooky Error")
|
|
})
|
|
})
|
|
|
|
it("should connect successfully with password", () => {
|
|
const mockCreds = {
|
|
host: "localhost",
|
|
port: 22,
|
|
username: "user",
|
|
password: "pass"
|
|
}
|
|
|
|
return sshConnection.connect(mockCreds).then(() => {
|
|
expect(mockSSH2Client.connect).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
host: mockCreds.host,
|
|
port: mockCreds.port,
|
|
username: mockCreds.username,
|
|
password: mockCreds.password,
|
|
tryKeyboard: true
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
it("should fail after max authentication attempts", () => {
|
|
const mockCreds = {
|
|
host: "localhost",
|
|
port: 22,
|
|
username: "user",
|
|
password: "wrongpass"
|
|
}
|
|
|
|
const attempts = DEFAULTS.MAX_AUTH_ATTEMPTS + 1
|
|
mockSSH2Client.connect.mockImplementation(() => {
|
|
process.nextTick(() => {
|
|
registeredEventHandlers.error(new Error("Authentication failed"))
|
|
})
|
|
})
|
|
|
|
return sshConnection.connect(mockCreds).catch((error) => {
|
|
expect(error).toBeInstanceOf(SSHConnectionError)
|
|
expect(error.message).toBe("All authentication methods failed")
|
|
expect(mockSSH2Client.connect.mock.calls.length).toBe(attempts)
|
|
})
|
|
})
|
|
|
|
describe("key authentication", () => {
|
|
const validPrivateKey = `-----BEGIN RSA PRIVATE KEY-----
|
|
MIIEpTestKeyContentHere
|
|
-----END RSA PRIVATE KEY-----`
|
|
|
|
it("should try private key first when both password and key are provided", () => {
|
|
const mockCreds = {
|
|
host: "localhost",
|
|
port: 22,
|
|
username: "user",
|
|
password: "pass",
|
|
privatekey: validPrivateKey
|
|
}
|
|
|
|
return sshConnection.connect(mockCreds).then(() => {
|
|
expect(mockSSH2Client.connect).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
privateKey: validPrivateKey,
|
|
username: mockCreds.username
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
it("should fall back to password after key authentication failure", () => {
|
|
const mockCreds = {
|
|
host: "localhost",
|
|
port: 22,
|
|
username: "user",
|
|
password: "pass",
|
|
privatekey: validPrivateKey
|
|
}
|
|
|
|
let authAttempts = 0
|
|
mockSSH2Client.connect
|
|
.mockImplementationOnce(() => {
|
|
process.nextTick(() => {
|
|
authAttempts += 1
|
|
registeredEventHandlers.error(
|
|
new Error("Key authentication failed")
|
|
)
|
|
})
|
|
})
|
|
.mockImplementationOnce(() => {
|
|
process.nextTick(() => {
|
|
authAttempts += 1
|
|
registeredEventHandlers.ready()
|
|
})
|
|
})
|
|
|
|
return sshConnection.connect(mockCreds).then(() => {
|
|
expect(authAttempts).toBe(2)
|
|
expect(mockSSH2Client.connect).toHaveBeenCalledTimes(2)
|
|
// Verify second attempt used password
|
|
expect(mockSSH2Client.connect).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
password: mockCreds.password
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
it("should reject invalid private key format", () => {
|
|
const mockCreds = {
|
|
host: "localhost",
|
|
port: 22,
|
|
username: "user",
|
|
privatekey: "invalid-key-format"
|
|
}
|
|
|
|
return sshConnection.connect(mockCreds).catch((error) => {
|
|
expect(error).toBeInstanceOf(SSHConnectionError)
|
|
expect(error.message).toBe("Invalid private key format")
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("shell", () => {
|
|
beforeEach(() => {
|
|
sshConnection.conn = mockSSH2Client
|
|
})
|
|
|
|
it("should open shell successfully", () => {
|
|
const mockStream = {
|
|
on: jest.fn(),
|
|
stderr: { on: jest.fn() }
|
|
}
|
|
|
|
mockSSH2Client.shell.mockImplementation((options, callback) => {
|
|
process.nextTick(() => callback(null, mockStream))
|
|
})
|
|
|
|
return sshConnection.shell().then((result) => {
|
|
expect(result).toBe(mockStream)
|
|
expect(sshConnection.stream).toBe(mockStream)
|
|
})
|
|
})
|
|
|
|
it("should handle shell creation errors", () => {
|
|
mockSSH2Client.shell.mockImplementation((options, callback) => {
|
|
process.nextTick(() => callback(new Error("Shell error")))
|
|
})
|
|
|
|
return sshConnection.shell().catch((error) => {
|
|
expect(error.message).toBe("Shell error")
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("resizeTerminal", () => {
|
|
it("should resize terminal if stream exists", () => {
|
|
const mockStream = {
|
|
setWindow: jest.fn()
|
|
}
|
|
sshConnection.stream = mockStream
|
|
|
|
sshConnection.resizeTerminal(80, 24)
|
|
expect(mockStream.setWindow).toHaveBeenCalledWith(80, 24)
|
|
})
|
|
|
|
it("should do nothing if stream does not exist", () => {
|
|
sshConnection.stream = null
|
|
expect(() => sshConnection.resizeTerminal(80, 24)).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe("end", () => {
|
|
it("should close stream and connection", () => {
|
|
const mockStream = {
|
|
end: jest.fn()
|
|
}
|
|
sshConnection.stream = mockStream
|
|
sshConnection.conn = mockSSH2Client
|
|
|
|
sshConnection.end()
|
|
|
|
expect(mockStream.end).toHaveBeenCalled()
|
|
expect(mockSSH2Client.end).toHaveBeenCalled()
|
|
expect(sshConnection.stream).toBeNull()
|
|
expect(sshConnection.conn).toBeNull()
|
|
})
|
|
|
|
it("should handle cleanup when no stream or connection exists", () => {
|
|
sshConnection.stream = null
|
|
sshConnection.conn = null
|
|
|
|
expect(() => sshConnection.end()).not.toThrow()
|
|
})
|
|
})
|
|
})
|