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

View file

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

View file

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

View file

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

View file

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

View file

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