feat: update tests to use node:test and node:assert/strict and eliminate jest #383

This commit is contained in:
Bill Church 2024-12-14 13:48:53 +00:00
parent db891ecb92
commit c802350442
No known key found for this signature in database
9 changed files with 969 additions and 6424 deletions

View file

@ -1,14 +0,0 @@
// jest.config.js
export default {
preset: "ts-jest",
testEnvironment: "node",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/app/$1"
},
transform: {
"^.+\\.tsx?$": ["ts-jest", {
useESM: true
}]
},
extensionsToTreatAsEsm: [".ts"]
}

6064
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -50,7 +50,7 @@
"lint": "eslint app",
"lint:fix": "eslint app --fix",
"watch": "NODE_ENV=development DEBUG=webssh* node --watch index.js",
"test": "jest",
"test": "node --test tests/*.test.js",
"release": "standard-version -a -s --release-as patch --commit-all",
"release:dry-run": "standard-version -a -s --release-as patch --dry-run",
"publish:dry-run": "npm publish --dry-run",
@ -61,12 +61,6 @@
"release:minor": "npm run release -- --release-as minor",
"release:patch": "npm run release -- --release-as patch"
},
"jest": {
"testEnvironment": "node",
"testMatch": [
"**/tests/**/*.test.js"
]
},
"standard": {
"ignore": [
"bin/*",
@ -83,12 +77,11 @@
"@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"eslint": "^9.16.0",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-security": "^3.0.1",
"jest": "^21.2.1",
"prettier": "^3.4.2",
"standard-version": "^4.4.0",
"ts-node": "^10.9.2",

View file

@ -1,11 +1,10 @@
// server
// tests/crypto-utils.test.js
import test from 'node:test'
import assert from 'node:assert/strict'
import { generateSecureSecret } from '../app/crypto-utils.js'
const { generateSecureSecret } = require("../app/crypto-utils")
describe("generateSecureSecret", () => {
it("should generate a 64-character hex string", () => {
test('generateSecureSecret', async (t) => {
await t.test('should generate a 64-character hex string', () => {
const secret = generateSecureSecret()
expect(secret).toMatch(/^[0-9a-f]{64}$/)
assert.match(secret, /^[0-9a-f]{64}$/)
})
})

View file

@ -1,88 +1,81 @@
const {
WebSSH2Error,
ConfigError,
SSHConnectionError,
handleError
} = require("../app/errors")
const { logError } = require("../app/logger")
const { HTTP, MESSAGES } = require("../app/constants")
import test from 'node:test'
import assert from 'node:assert/strict'
import { WebSSH2Error, ConfigError, SSHConnectionError, handleError } from '../app/errors.js'
import { logError } from '../app/logger.js'
import { HTTP, MESSAGES } from '../app/constants.js'
jest.mock("../app/logger", () => ({
logError: jest.fn(),
createNamespacedDebug: jest.fn(() => jest.fn())
}))
// Mock logger
const mockLogError = () => {}
describe("errors", () => {
afterEach(() => {
jest.clearAllMocks()
test('errors', async (t) => {
t.beforeEach(() => {
// Reset mocks between tests
})
describe("WebSSH2Error", () => {
it("should create a WebSSH2Error with correct properties", () => {
const error = new WebSSH2Error("Test error", "TEST_CODE")
expect(error).toBeInstanceOf(Error)
expect(error.name).toBe("WebSSH2Error")
expect(error.message).toBe("Test error")
expect(error.code).toBe("TEST_CODE")
await t.test('WebSSH2Error', async (t) => {
await t.test('should create WebSSH2Error with correct properties', () => {
const error = new WebSSH2Error('Test error', 'TEST_CODE')
assert.ok(error instanceof Error)
assert.equal(error.name, 'WebSSH2Error')
assert.equal(error.message, 'Test error')
assert.equal(error.code, 'TEST_CODE')
})
})
describe("ConfigError", () => {
it("should create a ConfigError with correct properties", () => {
const error = new ConfigError("Config error")
expect(error).toBeInstanceOf(WebSSH2Error)
expect(error.name).toBe("ConfigError")
expect(error.message).toBe("Config error")
expect(error.code).toBe(MESSAGES.CONFIG_ERROR)
await t.test('ConfigError', async (t) => {
await t.test('should create ConfigError with correct properties', () => {
const error = new ConfigError('Config error')
assert.ok(error instanceof WebSSH2Error)
assert.equal(error.name, 'ConfigError')
assert.equal(error.message, 'Config error')
assert.equal(error.code, MESSAGES.CONFIG_ERROR)
})
})
describe("SSHConnectionError", () => {
it("should create a SSHConnectionError with correct properties", () => {
const error = new SSHConnectionError("SSH connection error")
expect(error).toBeInstanceOf(WebSSH2Error)
expect(error.name).toBe("SSHConnectionError")
expect(error.message).toBe("SSH connection error")
expect(error.code).toBe(MESSAGES.SSH_CONNECTION_ERROR)
await t.test('SSHConnectionError', async (t) => {
await t.test('should create SSHConnectionError with correct properties', () => {
const error = new SSHConnectionError('SSH connection error')
assert.ok(error instanceof WebSSH2Error)
assert.equal(error.name, 'SSHConnectionError')
assert.equal(error.message, 'SSH connection error')
assert.equal(error.code, MESSAGES.SSH_CONNECTION_ERROR)
})
})
describe("handleError", () => {
await t.test('handleError', async (t) => {
let responseData
const mockRes = {
status: jest.fn(() => mockRes),
json: jest.fn()
status: function(code) {
this.statusCode = code
return this
},
json: function(data) {
responseData = data
return this
}
}
it("should handle WebSSH2Error correctly", () => {
const error = new WebSSH2Error("Test error", "TEST_CODE")
await t.test('should handle WebSSH2Error correctly', () => {
const error = new WebSSH2Error('Test error', 'TEST_CODE')
handleError(error, mockRes)
expect(logError).toHaveBeenCalledWith("Test error", error)
expect(mockRes.status).toHaveBeenCalledWith(HTTP.INTERNAL_SERVER_ERROR)
expect(mockRes.json).toHaveBeenCalledWith({
error: "Test error",
code: "TEST_CODE"
assert.deepEqual(responseData, {
error: 'Test error',
code: 'TEST_CODE'
})
})
it("should handle generic Error correctly", () => {
const error = new Error("Generic error")
await t.test('should handle generic Error correctly', () => {
const error = new Error('Generic error')
handleError(error, mockRes)
expect(logError).toHaveBeenCalledWith(MESSAGES.UNEXPECTED_ERROR, error)
expect(mockRes.status).toHaveBeenCalledWith(HTTP.INTERNAL_SERVER_ERROR)
expect(mockRes.json).toHaveBeenCalledWith({
assert.deepEqual(responseData, {
error: MESSAGES.UNEXPECTED_ERROR
})
})
it("should not send response if res is not provided", () => {
const error = new Error("No response error")
handleError(error)
expect(logError).toHaveBeenCalledWith(MESSAGES.UNEXPECTED_ERROR, error)
expect(mockRes.status).not.toHaveBeenCalled()
expect(mockRes.json).not.toHaveBeenCalled()
await t.test('should not send response if res is not provided', () => {
const error = new Error('No response error')
const result = handleError(error)
assert.equal(result, undefined)
})
})
})

View file

@ -1,48 +1,33 @@
// server
// tests/logger.test.js
import { test, mock } from 'node:test'
import assert from 'node:assert/strict'
import { createNamespacedDebug, logError } from '../app/logger.js'
const createDebug = require("debug")
const { createNamespacedDebug, logError } = require("../app/logger")
jest.mock("debug")
describe("logger", () => {
beforeEach(() => {
jest.clearAllMocks()
console.error = jest.fn()
})
describe("createNamespacedDebug", () => {
it("should create a debug function with the correct namespace", () => {
const mockDebug = jest.fn()
createDebug.mockReturnValue(mockDebug)
const result = createNamespacedDebug("test")
expect(createDebug).toHaveBeenCalledWith("webssh2:test")
expect(result).toBe(mockDebug)
})
})
describe("logError", () => {
it("should log an error message without an error object", () => {
const message = "Test error message"
logError(message)
expect(console.error).toHaveBeenCalledWith(message)
expect(console.error).toHaveBeenCalledTimes(1)
})
it("should log an error message with an error object", () => {
const message = "Test error message"
const error = new Error("Test error")
logError(message, error)
expect(console.error).toHaveBeenCalledWith(message)
expect(console.error).toHaveBeenCalledWith("ERROR: Error: Test error")
expect(console.error).toHaveBeenCalledTimes(2)
})
})
test('createNamespacedDebug creates debug function with correct namespace', (t) => {
const debug = createNamespacedDebug('test')
assert.equal(typeof debug, 'function')
assert.equal(debug.namespace, 'webssh2:test')
})
test('logError logs error message without error object', (t) => {
const consoleMock = mock.method(console, 'error')
logError('test message')
assert.equal(consoleMock.mock.calls.length, 1)
assert.deepEqual(consoleMock.mock.calls[0].arguments, ['test message'])
consoleMock.mock.restore()
})
test('logError logs error message with error object', (t) => {
const consoleMock = mock.method(console, 'error')
const testError = new Error('test error')
logError('test message', testError)
assert.equal(consoleMock.mock.calls.length, 2)
assert.deepEqual(consoleMock.mock.calls[0].arguments, ['test message'])
assert.deepEqual(consoleMock.mock.calls[1].arguments, [`ERROR: ${testError}`])
consoleMock.mock.restore()
})

View file

@ -1,95 +1,86 @@
// server
// tests/socket.test.js
import { describe, it, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import socketHandler from '../app/socket.js'
const EventEmitter = require("events")
const socketHandler = require("../app/socket")
// const WebSSH2Socket = require("../app/socket")
jest.mock("../app/ssh")
describe("socketHandler", () => {
let io
let socket
let config
describe('Socket Handler', () => {
let io, mockSocket, mockConfig
beforeEach(() => {
socket = new EventEmitter()
socket.id = "test-socket-id"
socket.handshake = {
session: {}
}
socket.emit = jest.fn()
// Mock Socket.IO instance
io = new EventEmitter()
io.on = mock.fn(io.on)
io = {
on: jest.fn((event, callback) => {
if (event === "connection") {
callback(socket)
}
})
}
config = {
ssh: {
term: "xterm-color"
},
options: {
allowreauth: true
// Mock socket instance
mockSocket = new EventEmitter()
mockSocket.id = 'test-socket-id'
mockSocket.handshake = {
session: {
save: mock.fn((cb) => cb())
}
}
mockSocket.emit = mock.fn()
mockSocket.disconnect = mock.fn()
socketHandler(io, config)
// Mock config
mockConfig = {
ssh: {
term: 'xterm-color',
readyTimeout: 20000,
keepaliveInterval: 120000,
keepaliveCountMax: 10
},
options: {
allowReauth: true,
allowReplay: true,
allowReconnect: true
},
user: {}
}
// Initialize socket handler
socketHandler(io, mockConfig)
})
afterEach(() => {
jest.clearAllMocks()
it('should set up connection listener on io instance', () => {
assert.equal(io.on.mock.calls.length, 1)
assert.equal(io.on.mock.calls[0].arguments[0], 'connection')
assert.equal(typeof io.on.mock.calls[0].arguments[1], 'function')
})
test("should set up connection listener on io", () => {
expect(io.on).toHaveBeenCalledWith("connection", expect.any(Function))
})
it('should create new WebSSH2Socket instance on connection', () => {
const connectionHandler = io.on.mock.calls[0].arguments[1]
connectionHandler(mockSocket)
test("should set up authenticate event listener on socket", () => {
expect(socket.listeners("authenticate")).toHaveLength(1)
})
test("should set up terminal event listener on socket", () => {
expect(socket.listeners("terminal")).toHaveLength(1)
})
test("should set up disconnect event listener on socket", () => {
expect(socket.listeners("disconnect")).toHaveLength(1)
})
test("should emit request_auth when not authenticated", () => {
expect(socket.emit).toHaveBeenCalledWith("authentication", {
action: "request_auth"
// Verify socket emits authentication request when no basic auth
assert.equal(mockSocket.emit.mock.calls[0].arguments[0], 'authentication')
assert.deepEqual(mockSocket.emit.mock.calls[0].arguments[1], {
action: 'request_auth'
})
})
test("should handle authenticate event", () => {
const creds = {
username: "testuser",
password: "testpass",
host: "testhost",
port: 22
}
socket.emit("authenticate", creds)
// build out later
})
test("should handle terminal event", () => {
const terminalData = {
term: "xterm",
rows: 24,
cols: 80
}
socket.emit("terminal", terminalData)
// build out later
})
test("should handle disconnect event", () => {
const reason = "test-reason"
socket.emit("disconnect", reason)
// build out later
})
})
describe('Authentication Flow', () => {
it.todo('should handle keyboard-interactive authentication')
it.todo('should process successful authentication')
it.todo('should handle invalid credentials')
it.todo('should respect disableInteractiveAuth setting')
})
describe('Terminal Operations', () => {
it.todo('should handle terminal resize events')
it.todo('should process terminal data correctly')
it.todo('should maintain terminal session state')
})
describe('Control Commands', () => {
it.todo('should process reauth commands')
it.todo('should handle credential replay')
it.todo('should update UI elements appropriately')
})
describe('Session Management', () => {
it.todo('should clean up on disconnect')
it.todo('should manage session state')
it.todo('should clear credentials properly')
})

View file

@ -1,291 +1,268 @@
/* eslint-disable jest/no-conditional-expect */
// server
// tests/ssh.test.js
import ssh2 from 'ssh2'
import crypto from 'crypto'
import { test, describe, beforeEach, afterEach } from 'node:test'
import { strict as assert } from 'assert'
const { Server } = ssh2
import SSHConnection from '../app/ssh.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", () => {
describe('SSHConnection', () => {
let sshServer
let sshConnection
let mockConfig
let mockSSH2Client
let registeredEventHandlers
const TEST_PORT = 2222
const TEST_CREDENTIALS = {
username: 'testuser',
password: 'testpass',
}
const { privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs1',
format: 'pem',
},
})
const mockConfig = {
ssh: {
algorithms: {},
readyTimeout: 2000,
keepaliveInterval: 1000,
keepaliveCountMax: 3,
term: 'xterm',
},
user: {
privateKey: null,
},
}
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
sshServer = new Server(
{
hostKeys: [privateKey],
},
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()
(client) => {
client.on('authentication', (ctx) => {
if (
ctx.method === 'password' &&
ctx.username === TEST_CREDENTIALS.username &&
ctx.password === TEST_CREDENTIALS.password
) {
ctx.accept()
} else {
ctx.reject()
}
})
}),
shell: jest.fn(),
end: jest.fn()
}
SSH2.Client.mockImplementation(() => mockSSH2Client)
client.on('ready', () => {
client.on('session', (accept) => {
const session = accept()
session.once('pty', (accept) => {
accept()
})
session.once('shell', (accept) => {
const stream = accept()
stream.write('Connected to test server\r\n')
})
})
})
}
)
sshServer.listen(TEST_PORT)
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
if (sshConnection) {
sshConnection.end()
}
return new Promise((resolve) => {
sshServer.close(resolve)
})
})
expect(mockStream.end).toHaveBeenCalled()
expect(mockSSH2Client.end).toHaveBeenCalled()
expect(sshConnection.stream).toBeNull()
expect(sshConnection.conn).toBeNull()
test('should connect with valid credentials', async () => {
const credentials = {
host: 'localhost',
port: TEST_PORT,
username: TEST_CREDENTIALS.username,
password: TEST_CREDENTIALS.password,
}
const connection = await sshConnection.connect(credentials)
assert.ok(connection, 'Connection should be established')
})
test('should reject connection with invalid credentials', async () => {
const invalidCredentials = {
host: 'localhost',
port: TEST_PORT,
username: 'wronguser',
password: 'wrongpass',
}
try {
await sshConnection.connect(invalidCredentials)
assert.fail('Connection should have been rejected')
} catch (error) {
assert.equal(error.name, 'SSHConnectionError')
assert.equal(error.message, 'All authentication methods failed')
}
})
test('should connect using private key authentication', async () => {
const credentials = {
host: 'localhost',
port: TEST_PORT,
username: TEST_CREDENTIALS.username,
privateKey: privateKey,
}
// Update server auth handler to accept private key
sshServer.removeAllListeners('connection')
sshServer.on('connection', (client) => {
client.on('authentication', (ctx) => {
if (ctx.method === 'publickey' && ctx.username === TEST_CREDENTIALS.username) {
ctx.accept()
} else {
ctx.reject()
}
})
client.on('ready', () => {
client.on('session', (accept) => {
accept()
})
})
})
it("should handle cleanup when no stream or connection exists", () => {
sshConnection.stream = null
sshConnection.conn = null
const connection = await sshConnection.connect(credentials)
assert.ok(connection, 'Connection should be established using private key')
})
expect(() => sshConnection.end()).not.toThrow()
test('should reject invalid private key format', async () => {
const invalidPrivateKey = 'not-a-valid-private-key-format'
const credentials = {
host: 'localhost',
port: TEST_PORT,
username: TEST_CREDENTIALS.username,
privateKey: invalidPrivateKey,
}
try {
await sshConnection.connect(credentials)
assert.fail('Connection should have been rejected')
} catch (error) {
assert.equal(error.name, 'SSHConnectionError')
assert.equal(error.message, 'Invalid private key format')
}
})
test('should resize terminal when stream exists', async () => {
const credentials = {
host: 'localhost',
port: TEST_PORT,
username: TEST_CREDENTIALS.username,
password: TEST_CREDENTIALS.password,
}
// Connect and create shell
await sshConnection.connect(credentials)
await sshConnection.shell({ term: 'xterm' })
// Mock the setWindow method on stream
let windowResized = false
sshConnection.stream.setWindow = (rows, cols) => {
windowResized = true
assert.equal(rows, 24)
assert.equal(cols, 80)
}
// Test resize
sshConnection.resizeTerminal(24, 80)
assert.ok(windowResized, 'Terminal should be resized')
})
test('should try private key first when both password and key are provided', async () => {
const authAttemptOrder = []
sshServer.removeAllListeners('connection')
sshServer.on('connection', (client) => {
client.on('authentication', (ctx) => {
authAttemptOrder.push(ctx.method)
if (ctx.method === 'publickey' && ctx.username === TEST_CREDENTIALS.username) {
return ctx.accept()
}
if (
ctx.method === 'password' &&
ctx.username === TEST_CREDENTIALS.username &&
ctx.password === TEST_CREDENTIALS.password
) {
return ctx.accept()
}
ctx.reject(['publickey', 'password'])
})
client.on('ready', () => {
client.on('session', (accept) => {
const session = accept()
session.once('pty', (accept) => accept())
session.once('shell', (accept) => accept())
})
})
})
const credentials = {
host: 'localhost',
port: TEST_PORT,
username: TEST_CREDENTIALS.username,
privateKey: privateKey,
password: TEST_CREDENTIALS.password,
}
const connection = await sshConnection.connect(credentials)
assert.ok(connection, 'Connection should be established')
assert.ok(
authAttemptOrder.includes('publickey'),
'Private key authentication should be attempted'
)
})
test('should handle connection failures', async () => {
const credentials = {
host: 'localhost',
port: 9999,
username: TEST_CREDENTIALS.username,
password: TEST_CREDENTIALS.password,
}
try {
await sshConnection.connect(credentials)
assert.fail('Connection should have failed')
} catch (error) {
assert.equal(error.name, 'SSHConnectionError')
assert.equal(error.message, 'All authentication methods failed')
}
})
test('should handle connection timeout', async () => {
const credentials = {
host: '240.0.0.0',
port: TEST_PORT,
username: TEST_CREDENTIALS.username,
password: TEST_CREDENTIALS.password,
}
try {
await sshConnection.connect(credentials)
assert.fail('Connection should have timed out')
} catch (error) {
assert.equal(error.name, 'SSHConnectionError')
assert.equal(error.message, 'All authentication methods failed')
}
})
})

View file

@ -1,7 +1,9 @@
// server
// tests/utils.test.js
const {
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'
import {
deepMerge,
getValidatedHost,
getValidatedPort,
@ -9,214 +11,221 @@ const {
maskSensitiveData,
modifyHtml,
validateConfig,
validateSshTerm
} = require("../app/utils")
validateSshTerm,
} from '../app/utils.js'
describe("utils", () => {
describe("deepMerge", () => {
it("should merge two objects deeply", () => {
const obj1 = { a: { b: 1 }, c: 2 }
const obj2 = { a: { d: 3 }, e: 4 }
const result = deepMerge(obj1, obj2)
expect(result).toEqual({ a: { b: 1, d: 3 }, c: 2, e: 4 })
})
})
describe("getValidatedHost", () => {
it("should return IP address unchanged", () => {
expect(getValidatedHost("192.168.1.1")).toBe("192.168.1.1")
})
it("should escape hostname", () => {
expect(getValidatedHost("example.com")).toBe("example.com")
expect(getValidatedHost("<script>alert('xss')</script>")).toBe(
"&lt;script&gt;alert(&#x27;xss&#x27;)&lt;&#x2F;script&gt;"
)
})
})
describe("getValidatedPort", () => {
it("should return valid port number", () => {
expect(getValidatedPort("22")).toBe(22)
expect(getValidatedPort("8080")).toBe(8080)
})
it("should return default port for invalid input", () => {
expect(getValidatedPort("invalid")).toBe(22)
expect(getValidatedPort("0")).toBe(22)
expect(getValidatedPort("65536")).toBe(22)
})
})
describe("isValidCredentials", () => {
it("should return true for valid credentials", () => {
const validCreds = {
username: "user",
password: "pass",
host: "example.com",
port: 22
}
expect(isValidCredentials(validCreds)).toBe(true)
})
it("should return false for invalid credentials", () => {
expect(isValidCredentials(null)).toBe(false)
expect(isValidCredentials({})).toBe(false)
expect(isValidCredentials({ username: "user" })).toBe(false)
})
})
describe("maskSensitiveData", () => {
it("should mask simple password property", () => {
const testObj = { username: "user", password: "secret123" }
const maskedObj = maskSensitiveData(testObj)
console.log("maskedObj.password.length: ", maskedObj.password.length)
expect(maskedObj.username).toBe("user")
expect(maskedObj.password).not.toBe("secret123")
expect(maskedObj.password.length).toBeGreaterThanOrEqual(3)
expect(maskedObj.password.length).toBeLessThanOrEqual(9)
})
it("should mask array elements when property is specified", () => {
const testObj = {
action: "keyboard-interactive",
responses: ["sensitive_password", "another_sensitive_value"]
}
const maskedObj = maskSensitiveData(testObj, {
properties: ["responses"]
})
expect(maskedObj.action).toBe("keyboard-interactive")
expect(Array.isArray(maskedObj.responses)).toBe(true)
expect(maskedObj.responses).toHaveLength(2)
expect(maskedObj.responses[0]).not.toBe("sensitive_password")
expect(maskedObj.responses[1]).not.toBe("another_sensitive_value")
expect(maskedObj.responses[0]).toHaveLength(8)
expect(maskedObj.responses[1]).toHaveLength(8)
})
it("should not mask non-specified properties", () => {
const testObj = {
username: "user",
password: "secret",
data: ["public_info", "not_sensitive"]
}
const maskedObj = maskSensitiveData(testObj, {
properties: ["password"]
})
expect(maskedObj.username).toBe("user")
expect(maskedObj.password).not.toBe("secret")
expect(maskedObj.data).toEqual(["public_info", "not_sensitive"])
})
it("should handle nested objects", () => {
const testObj = {
user: {
name: "John",
credentials: {
password: "topsecret",
token: "abcdef123456"
}
}
}
const maskedObj = maskSensitiveData(testObj)
expect(maskedObj.user.name).toBe("John")
expect(maskedObj.user.credentials.password).not.toBe("topsecret")
expect(maskedObj.user.credentials.token).not.toBe("abcdef123456")
})
})
describe("modifyHtml", () => {
it("should modify HTML content", () => {
const html = "window.webssh2Config = null;"
const config = { key: "value" }
const content = `window.webssh2Config = ${JSON.stringify(config)};`
const modified = modifyHtml(html, config)
expect(modified).toContain('window.webssh2Config = {"key":"value"};')
})
})
describe("validateConfig", () => {
it("should validate correct config", () => {
const validConfig = {
listen: {
ip: "0.0.0.0",
port: 2222
},
http: {
origins: ["http://localhost:8080"]
},
user: {
name: null,
password: null,
privateKey: null
},
ssh: {
host: null,
port: 22,
localAddress: null,
localPort: null,
term: "xterm-color",
readyTimeout: 20000,
keepaliveInterval: 120000,
keepaliveCountMax: 10,
allowedSubnets: []
},
header: {
text: null,
background: "green"
},
options: {
challengeButton: true,
autoLog: false,
allowReauth: true,
allowReconnect: true,
allowReplay: true
},
algorithms: {
kex: [
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha1"
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm",
"aes128-gcm@openssh.com",
"aes256-gcm",
"aes256-gcm@openssh.com",
"aes256-cbc"
],
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1"],
compress: ["none", "zlib@openssh.com", "zlib"]
}
}
expect(() => validateConfig(validConfig)).not.toThrow()
})
it("should throw error for invalid config", () => {
const invalidConfig = {}
expect(() => validateConfig(invalidConfig)).toThrow()
})
})
describe("validateSshTerm", () => {
it("should return valid SSH term", () => {
expect(validateSshTerm("xterm")).toBe("xterm")
expect(validateSshTerm("xterm-256color")).toBe("xterm-256color")
})
it("should return null for invalid SSH term", () => {
expect(validateSshTerm("")).toBe(null)
expect(validateSshTerm("<script>alert('xss')</script>")).toBe(null)
})
describe('deepMerge', () => {
test('merges nested objects correctly', () => {
const target = { a: { b: 1 }, c: 3 }
const source = { a: { d: 2 }, e: 4 }
const result = deepMerge(target, source)
assert.deepEqual(result, { a: { b: 1, d: 2 }, c: 3, e: 4 })
})
})
describe('getValidatedHost', () => {
test('returns valid IP unchanged', () => {
assert.equal(getValidatedHost('192.168.1.1'), '192.168.1.1')
})
test('escapes hostname with potential XSS', () => {
assert.equal(
getValidatedHost('host<script>alert(1)</script>'),
'host&lt;script&gt;alert(1)&lt;&#x2F;script&gt;'
)
})
})
describe('getValidatedPort', () => {
test('returns valid port number', () => {
assert.equal(getValidatedPort('22'), 22)
})
test('returns default port for invalid input', () => {
assert.equal(getValidatedPort('0'), 22)
assert.equal(getValidatedPort('65536'), 22)
assert.equal(getValidatedPort('invalid'), 22)
})
})
describe('isValidCredentials', () => {
test('validates complete credentials', () => {
const validCreds = {
username: 'user',
password: 'pass',
host: 'localhost',
port: 22,
}
assert.equal(isValidCredentials(validCreds), true)
})
test('rejects incomplete credentials', () => {
const invalidCreds = {
username: 'user',
host: 'localhost',
}
assert.equal(isValidCredentials(invalidCreds), false)
})
})
describe('maskSensitiveData', () => {
test('masks password in object', () => {
const input = { username: 'user', password: 'secret' }
const masked = maskSensitiveData(input)
assert.equal(masked.password.includes('*'), true)
assert.equal(masked.username, 'user')
})
test('masks nested sensitive data', () => {
const input = {
user: {
credentials: {
password: 'secret',
},
},
}
const masked = maskSensitiveData(input)
assert.equal(masked.user.credentials.password.includes('*'), true)
})
})
describe('modifyHtml', () => {
test('injects config and modifies asset paths', () => {
const html = `
<script src="script.js"></script>
<script>window.webssh2Config = null;</script>
`
const config = { key: 'value' }
const modified = modifyHtml(html, config)
assert.ok(modified.includes('/ssh/assets/script.js'))
assert.ok(modified.includes('window.webssh2Config = {"key":"value"}'))
})
})
describe('validateConfig', () => {
test('validates correct config', () => {
const validConfig = {
listen: {
ip: '0.0.0.0',
port: 2222,
},
http: {
origins: ['http://localhost:2222'],
},
user: {
name: 'testuser',
password: 'testpass',
privateKey: null,
},
ssh: {
host: 'localhost',
port: 22,
term: 'xterm',
readyTimeout: 20000,
keepaliveInterval: 30000,
keepaliveCountMax: 10,
algorithms: {
kex: ['ecdh-sha2-nistp256'],
cipher: ['aes256-ctr'],
hmac: ['hmac-sha2-256'],
serverHostKey: ['ssh-rsa'],
compress: ['none'],
},
},
header: {
text: 'WebSSH2',
background: 'green',
},
options: {
challengeButton: false,
allowReauth: true,
allowReplay: true,
},
session: {
secret: 'mysecret',
name: 'webssh2',
},
}
assert.doesNotThrow(() => validateConfig(validConfig))
})
test('throws on missing required fields', () => {
const invalidConfig = {
listen: { ip: '0.0.0.0' }, // missing required port
http: { origins: [] },
user: { name: 'test' }, // missing required password
ssh: {
host: 'localhost',
port: 22,
term: 'xterm',
// missing required fields
},
header: {
text: null,
// missing required background
},
options: {
// missing required fields
},
// missing required session
}
assert.throws(() => validateConfig(invalidConfig))
})
test('throws on invalid field types', () => {
const invalidTypeConfig = {
listen: {
ip: 123, // should be string
port: '2222', // should be integer
},
http: {
origins: 'not-an-array', // should be array
},
user: {
name: true, // should be string or null
password: 123, // should be string or null
},
ssh: {
host: null,
port: 'invalid-port', // should be integer
term: 123, // should be string
readyTimeout: '1000', // should be integer
keepaliveInterval: false, // should be integer
keepaliveCountMax: [], // should be integer
algorithms: {
kex: 'not-an-array', // should be array
cipher: 'not-an-array', // should be array
hmac: 'not-an-array', // should be array
serverHostKey: 'not-an-array', // should be array
compress: 'not-an-array', // should be array
},
},
header: {
text: 123, // should be string or null
background: true, // should be string
},
options: {
challengeButton: 'not-boolean', // should be boolean
allowReauth: 1, // should be boolean
allowReplay: 'true', // should be boolean
},
session: {
secret: null, // should be string
name: [], // should be string
},
}
assert.throws(() => validateConfig(invalidTypeConfig))
})
})
describe('validateSshTerm', () => {
test('validates legitimate terminal types', () => {
assert.equal(validateSshTerm('xterm'), 'xterm')
assert.equal(validateSshTerm('xterm-256color'), 'xterm-256color')
})
test('returns null for invalid terminal strings', () => {
assert.equal(validateSshTerm('<script>alert(1)</script>'), null)
assert.equal(validateSshTerm(''), null)
})
})