chore: create initial tests

This commit is contained in:
Bill Church 2024-08-22 01:11:45 +00:00
parent 0899cb0efa
commit 1a5ebc649d
No known key found for this signature in database
8 changed files with 331 additions and 131 deletions

View file

@ -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

View file

@ -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
View 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
View 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
}

View file

@ -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,

View file

@ -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",

View 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
View 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(
"&lt;script&gt;alert(&#x27;xss&#x27;)&lt;&#x2F;script&gt;"
)
})
})
describe("getValidatedPort", () => {
it("should return valid port number", () => {
expect(getValidatedPort("22")).toBe(22)
expect(getValidatedPort("8080")).toBe(8080)
})
it("should return default port for invalid input", () => {
expect(getValidatedPort("invalid")).toBe(22)
expect(getValidatedPort("0")).toBe(22)
expect(getValidatedPort("65536")).toBe(22)
})
})
describe("isValidCredentials", () => {
it("should return true for valid credentials", () => {
const validCreds = {
username: "user",
password: "pass",
host: "example.com",
port: 22
}
expect(isValidCredentials(validCreds)).toBe(true)
})
it("should return false for invalid credentials", () => {
expect(isValidCredentials(null)).toBe(false)
expect(isValidCredentials({})).toBe(false)
expect(isValidCredentials({ username: "user" })).toBe(false)
})
})
describe("maskSensitiveData", () => {
it("should mask 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)
})
})
})