chore: create initial tests
This commit is contained in:
parent
0899cb0efa
commit
1a5ebc649d
8 changed files with 331 additions and 131 deletions
|
@ -2,9 +2,14 @@ extends:
|
|||
- airbnb-base
|
||||
- prettier
|
||||
- plugin:node/recommended
|
||||
- plugin:jest/recommended
|
||||
|
||||
plugins:
|
||||
- prettier
|
||||
- jest
|
||||
|
||||
env:
|
||||
jest/globals: true
|
||||
|
||||
rules:
|
||||
prettier/prettier: error
|
||||
|
@ -15,3 +20,9 @@ rules:
|
|||
object-shorthand: off
|
||||
class-methods-use-this: off
|
||||
semi: [2, never]
|
||||
|
||||
overrides:
|
||||
- files:
|
||||
- "**/*.test.js"
|
||||
env:
|
||||
jest: true
|
122
app/config.js
122
app/config.js
|
@ -1,8 +1,12 @@
|
|||
// server
|
||||
// app/config.js
|
||||
|
||||
const path = require("path")
|
||||
const fs = require("fs")
|
||||
const readConfig = require("read-config-ng")
|
||||
const Ajv = require("ajv")
|
||||
const { deepMerge, generateSecureSecret } = require("./utils")
|
||||
const { deepMerge, validateConfig } = require("./utils")
|
||||
const { generateSecureSecret } = require("./crypto-utils")
|
||||
const { createNamespacedDebug } = require("./logger")
|
||||
const { ConfigError, handleError } = require("./errors")
|
||||
const { DEFAULTS } = require("./constants")
|
||||
|
@ -67,127 +71,11 @@ const defaultConfig = {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for validating the config
|
||||
*/
|
||||
const configSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
listen: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ip: { type: "string", format: "ipv4" },
|
||||
port: { type: "integer", minimum: 1, maximum: 65535 }
|
||||
},
|
||||
required: ["ip", "port"]
|
||||
},
|
||||
http: {
|
||||
type: "object",
|
||||
properties: {
|
||||
origins: {
|
||||
type: "array",
|
||||
items: { type: "string" }
|
||||
}
|
||||
},
|
||||
required: ["origins"]
|
||||
},
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: ["string", "null"] },
|
||||
password: { type: ["string", "null"] }
|
||||
},
|
||||
required: ["name", "password"]
|
||||
},
|
||||
ssh: {
|
||||
type: "object",
|
||||
properties: {
|
||||
host: { type: ["string", "null"] },
|
||||
port: { type: "integer", minimum: 1, maximum: 65535 },
|
||||
term: { type: "string" },
|
||||
readyTimeout: { type: "integer" },
|
||||
keepaliveInterval: { type: "integer" },
|
||||
keepaliveCountMax: { type: "integer" }
|
||||
},
|
||||
required: [
|
||||
"host",
|
||||
"port",
|
||||
"term",
|
||||
"readyTimeout",
|
||||
"keepaliveInterval",
|
||||
"keepaliveCountMax"
|
||||
]
|
||||
},
|
||||
header: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: ["string", "null"] },
|
||||
background: { type: "string" }
|
||||
},
|
||||
required: ["text", "background"]
|
||||
},
|
||||
options: {
|
||||
type: "object",
|
||||
properties: {
|
||||
challengeButton: { type: "boolean" },
|
||||
autoLog: { type: "boolean" },
|
||||
allowReauth: { type: "boolean" },
|
||||
allowReconnect: { type: "boolean" },
|
||||
allowReplay: { type: "boolean" }
|
||||
},
|
||||
required: ["challengeButton", "allowReauth", "allowReplay"]
|
||||
},
|
||||
algorithms: {
|
||||
type: "object",
|
||||
properties: {
|
||||
kex: {
|
||||
type: "array",
|
||||
items: { type: "string" }
|
||||
},
|
||||
cipher: {
|
||||
type: "array",
|
||||
items: { type: "string" }
|
||||
},
|
||||
hmac: {
|
||||
type: "array",
|
||||
items: { type: "string" }
|
||||
},
|
||||
compress: {
|
||||
type: "array",
|
||||
items: { type: "string" }
|
||||
}
|
||||
},
|
||||
required: ["kex", "cipher", "hmac", "compress"]
|
||||
},
|
||||
session: {
|
||||
type: "object",
|
||||
properties: {
|
||||
secret: { type: "string" },
|
||||
name: { type: "string" }
|
||||
},
|
||||
required: ["secret", "name"]
|
||||
}
|
||||
},
|
||||
required: ["listen", "http", "user", "ssh", "header", "options", "algorithms"]
|
||||
}
|
||||
|
||||
function getConfigPath() {
|
||||
const nodeRoot = path.dirname(require.main.filename)
|
||||
return path.join(nodeRoot, "config.json")
|
||||
}
|
||||
|
||||
function validateConfig(config) {
|
||||
const ajv = new Ajv()
|
||||
const validate = ajv.compile(configSchema)
|
||||
const valid = validate(config)
|
||||
if (!valid) {
|
||||
throw new Error(
|
||||
`Config validation error: ${ajv.errorsText(validate.errors)}`
|
||||
)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = getConfigPath()
|
||||
|
||||
|
|
104
app/configSchema.js
Normal file
104
app/configSchema.js
Normal file
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Schema for validating the config
|
||||
*/
|
||||
const configSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
listen: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ip: { type: "string", format: "ipv4" },
|
||||
port: { type: "integer", minimum: 1, maximum: 65535 }
|
||||
},
|
||||
required: ["ip", "port"]
|
||||
},
|
||||
http: {
|
||||
type: "object",
|
||||
properties: {
|
||||
origins: {
|
||||
type: "array",
|
||||
items: { type: "string" }
|
||||
}
|
||||
},
|
||||
required: ["origins"]
|
||||
},
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: ["string", "null"] },
|
||||
password: { type: ["string", "null"] }
|
||||
},
|
||||
required: ["name", "password"]
|
||||
},
|
||||
ssh: {
|
||||
type: "object",
|
||||
properties: {
|
||||
host: { type: ["string", "null"] },
|
||||
port: { type: "integer", minimum: 1, maximum: 65535 },
|
||||
term: { type: "string" },
|
||||
readyTimeout: { type: "integer" },
|
||||
keepaliveInterval: { type: "integer" },
|
||||
keepaliveCountMax: { type: "integer" }
|
||||
},
|
||||
required: [
|
||||
"host",
|
||||
"port",
|
||||
"term",
|
||||
"readyTimeout",
|
||||
"keepaliveInterval",
|
||||
"keepaliveCountMax"
|
||||
]
|
||||
},
|
||||
header: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: ["string", "null"] },
|
||||
background: { type: "string" }
|
||||
},
|
||||
required: ["text", "background"]
|
||||
},
|
||||
options: {
|
||||
type: "object",
|
||||
properties: {
|
||||
challengeButton: { type: "boolean" },
|
||||
autoLog: { type: "boolean" },
|
||||
allowReauth: { type: "boolean" },
|
||||
allowReconnect: { type: "boolean" },
|
||||
allowReplay: { type: "boolean" }
|
||||
},
|
||||
required: ["challengeButton", "allowReauth", "allowReplay"]
|
||||
},
|
||||
algorithms: {
|
||||
type: "object",
|
||||
properties: {
|
||||
kex: {
|
||||
type: "array",
|
||||
items: { type: "string" }
|
||||
},
|
||||
cipher: {
|
||||
type: "array",
|
||||
items: { type: "string" }
|
||||
},
|
||||
hmac: {
|
||||
type: "array",
|
||||
items: { type: "string" }
|
||||
},
|
||||
compress: {
|
||||
type: "array",
|
||||
items: { type: "string" }
|
||||
}
|
||||
},
|
||||
required: ["kex", "cipher", "hmac", "compress"]
|
||||
},
|
||||
session: {
|
||||
type: "object",
|
||||
properties: {
|
||||
secret: { type: "string" },
|
||||
name: { type: "string" }
|
||||
},
|
||||
required: ["secret", "name"]
|
||||
}
|
||||
},
|
||||
required: ["listen", "http", "user", "ssh", "header", "options", "algorithms"]
|
||||
}
|
||||
module.exports = configSchema
|
16
app/crypto-utils.js
Normal file
16
app/crypto-utils.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
// server
|
||||
// app/crypto-utils.js
|
||||
|
||||
const crypto = require("crypto")
|
||||
|
||||
/**
|
||||
* Generates a secure random session secret
|
||||
* @returns {string} A random 32-byte hex string
|
||||
*/
|
||||
function generateSecureSecret() {
|
||||
return crypto.randomBytes(32).toString("hex")
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateSecureSecret
|
||||
}
|
14
app/utils.js
14
app/utils.js
|
@ -1,12 +1,11 @@
|
|||
// server
|
||||
// /app/utils.js
|
||||
const validator = require("validator")
|
||||
const crypto = require("crypto")
|
||||
const Ajv = require("ajv")
|
||||
const maskObject = require("jsmasker")
|
||||
const { createNamespacedDebug } = require("./logger")
|
||||
const { DEFAULTS, MESSAGES } = require("./constants")
|
||||
const { configSchema } = require("./config")
|
||||
const configSchema = require("./configSchema")
|
||||
|
||||
const debug = createNamespacedDebug("utils")
|
||||
|
||||
|
@ -34,14 +33,6 @@ function deepMerge(target, source) {
|
|||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a secure random session secret
|
||||
* @returns {string} A random 32-byte hex string
|
||||
*/
|
||||
function generateSecureSecret() {
|
||||
return crypto.randomBytes(32).toString("hex")
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a given host is an IP address or a hostname.
|
||||
* If it's a hostname, it escapes it for safety.
|
||||
|
@ -99,7 +90,7 @@ function getValidatedPort(portInput) {
|
|||
* @returns {boolean} - Returns true if the credentials are valid, otherwise false.
|
||||
*/
|
||||
function isValidCredentials(creds) {
|
||||
return (
|
||||
return !!(
|
||||
creds &&
|
||||
typeof creds.username === "string" &&
|
||||
typeof creds.password === "string" &&
|
||||
|
@ -189,7 +180,6 @@ function maskSensitiveData(obj, options) {
|
|||
|
||||
module.exports = {
|
||||
deepMerge,
|
||||
generateSecureSecret,
|
||||
getValidatedHost,
|
||||
getValidatedPort,
|
||||
isValidCredentials,
|
||||
|
|
|
@ -49,7 +49,12 @@
|
|||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"lint": "eslint src test",
|
||||
"watch": "node_modules/.bin/nodemon index.js"
|
||||
"watch": "node_modules/.bin/nodemon index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"testMatch": ["**/tests/**/*.test.js"]
|
||||
},
|
||||
"standard": {
|
||||
"ignore": [
|
||||
|
@ -62,8 +67,10 @@
|
|||
"eslint-config-airbnb-base": "^13.2.0",
|
||||
"eslint-config-prettier": "^4.3.0",
|
||||
"eslint-plugin-import": "^2.18.2",
|
||||
"eslint-plugin-jest": "^22.0.0",
|
||||
"eslint-plugin-node": "^8.0.0",
|
||||
"eslint-plugin-prettier": "^2.7.0",
|
||||
"jest": "^23.6.0",
|
||||
"nodemon": "^1.12.1",
|
||||
"prettier": "^1.19.1",
|
||||
"prettier-eslint": "^8.8.2",
|
||||
|
|
11
tests/crypto-utils.test.js
Normal file
11
tests/crypto-utils.test.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
// server
|
||||
// tests/crypto-utils.test.js
|
||||
|
||||
const { generateSecureSecret } = require("../app/crypto-utils")
|
||||
|
||||
describe("generateSecureSecret", () => {
|
||||
it("should generate a 64-character hex string", () => {
|
||||
const secret = generateSecureSecret()
|
||||
expect(secret).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
})
|
173
tests/utils.test.js
Normal file
173
tests/utils.test.js
Normal file
|
@ -0,0 +1,173 @@
|
|||
// server
|
||||
// tests/utils.test.js
|
||||
|
||||
const {
|
||||
deepMerge,
|
||||
getValidatedHost,
|
||||
getValidatedPort,
|
||||
isValidCredentials,
|
||||
maskSensitiveData,
|
||||
modifyHtml,
|
||||
validateConfig,
|
||||
validateSshTerm
|
||||
} = require("../app/utils")
|
||||
|
||||
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(
|
||||
"<script>alert('xss')</script>"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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 sensitive data", () => {
|
||||
const data = {
|
||||
username: "user",
|
||||
password: "secret",
|
||||
token: "12345"
|
||||
}
|
||||
const masked = maskSensitiveData(data)
|
||||
expect(masked.username).toBe("user")
|
||||
expect(masked.password).not.toBe("secret")
|
||||
expect(masked.token).not.toBe("12345")
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue