feat: update tests to use node:test and node:assert/strict and eliminate jest #383
This commit is contained in:
parent
db891ecb92
commit
c802350442
9 changed files with 969 additions and 6424 deletions
|
@ -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
6064
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
@ -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",
|
||||||
|
|
|
@ -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}$/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
|
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -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')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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<script>alert(1)</script>'
|
||||||
"<script>alert('xss')</script>"
|
)
|
||||||
)
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue