382 lines
9.7 KiB
JavaScript
382 lines
9.7 KiB
JavaScript
process.env.NODE_ENV = 'development'
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const os = require('node:os')
|
|
const path = require('node:path')
|
|
const net = require('node:net')
|
|
const { once } = require('node:events')
|
|
const findFreePort = require('find-free-port')
|
|
const FtpSrv = require('@electerm/ftp-srv')
|
|
const serialportModulePath = require.resolve('serialport')
|
|
const serialportExports = require(serialportModulePath)
|
|
const { MockBinding } = require('@serialport/binding-mock')
|
|
|
|
const globalState = require('../../src/app/server/global-state')
|
|
const { Ftp } = require('../../src/app/server/session-ftp')
|
|
const sessionSerial = require('../../src/app/server/session-serial')
|
|
const sessionTelnet = require('../../src/app/server/session-telnet')
|
|
|
|
const FTP_USERNAME = 'test'
|
|
const FTP_PASSWORD = 'test123'
|
|
const SERIAL_PATH = '/dev/electerm-test'
|
|
const TELNET_USERNAME = 'tester'
|
|
const TELNET_PASSWORD = 'electerm-test'
|
|
|
|
function makeTmpDir (prefix) {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
|
|
}
|
|
|
|
async function getFreePort (start = 30000, end = 39999) {
|
|
const [port] = await findFreePort(start, end, '127.0.0.1')
|
|
return port
|
|
}
|
|
|
|
async function startFtpServer () {
|
|
const root = makeTmpDir('electerm-ftp-test-')
|
|
const port = await getFreePort(31000, 31999)
|
|
const server = new FtpSrv({
|
|
url: `ftp://127.0.0.1:${port}`,
|
|
anonymous: false,
|
|
root
|
|
})
|
|
|
|
server.on('login', ({ username, password }, resolve, reject) => {
|
|
if (username === FTP_USERNAME && password === FTP_PASSWORD) {
|
|
return resolve({ root })
|
|
}
|
|
return reject(new Error('Invalid username or password'))
|
|
})
|
|
|
|
await server.listen()
|
|
|
|
return {
|
|
port,
|
|
root,
|
|
async close () {
|
|
await server.close()
|
|
fs.rmSync(root, { recursive: true, force: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
async function startTelnetServer () {
|
|
const port = await getFreePort(32000, 32999)
|
|
const sockets = new Set()
|
|
|
|
function waitForIdle (timeout = 5000) {
|
|
if (sockets.size === 0) {
|
|
return Promise.resolve()
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
reject(new Error('Timed out waiting for telnet sockets to close'))
|
|
}, timeout)
|
|
|
|
const interval = setInterval(() => {
|
|
if (sockets.size === 0) {
|
|
clearTimeout(timer)
|
|
clearInterval(interval)
|
|
resolve()
|
|
}
|
|
}, 25)
|
|
})
|
|
}
|
|
|
|
const server = net.createServer((socket) => {
|
|
sockets.add(socket)
|
|
socket.setEncoding('utf8')
|
|
|
|
let stage = 'username'
|
|
let buffer = ''
|
|
|
|
socket.write('login: ')
|
|
|
|
socket.on('data', (chunk) => {
|
|
buffer += chunk
|
|
|
|
while (buffer.includes('\n')) {
|
|
const endIndex = buffer.indexOf('\n')
|
|
const line = buffer.slice(0, endIndex).replace(/\r$/, '')
|
|
buffer = buffer.slice(endIndex + 1)
|
|
|
|
if (stage === 'username') {
|
|
if (line === TELNET_USERNAME) {
|
|
stage = 'password'
|
|
socket.write('Password: ')
|
|
} else {
|
|
socket.write('login incorrect\r\nlogin: ')
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (stage === 'password') {
|
|
if (line === TELNET_PASSWORD) {
|
|
stage = 'shell'
|
|
socket.write('Welcome to electerm\r\n$ ')
|
|
} else {
|
|
socket.write('Login failed\r\n')
|
|
socket.end()
|
|
}
|
|
continue
|
|
}
|
|
|
|
socket.write(`echo:${line}\r\n$ `)
|
|
}
|
|
})
|
|
|
|
socket.on('close', () => {
|
|
sockets.delete(socket)
|
|
})
|
|
})
|
|
|
|
server.listen(port, '127.0.0.1')
|
|
await once(server, 'listening')
|
|
|
|
return {
|
|
port,
|
|
waitForIdle,
|
|
async close () {
|
|
await waitForIdle().catch(() => {})
|
|
for (const socket of sockets) {
|
|
socket.destroy()
|
|
}
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error) {
|
|
reject(error)
|
|
} else {
|
|
resolve()
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function waitForText (emitter, matcher, timeout = 5000) {
|
|
return new Promise((resolve, reject) => {
|
|
let output = ''
|
|
const timer = setTimeout(() => {
|
|
cleanup()
|
|
reject(new Error(`Timed out waiting for text. Received: ${output}`))
|
|
}, timeout)
|
|
|
|
const onData = (chunk) => {
|
|
output += chunk.toString()
|
|
if (matcher(output, chunk)) {
|
|
cleanup()
|
|
resolve(output)
|
|
}
|
|
}
|
|
|
|
const onClose = () => {
|
|
cleanup()
|
|
reject(new Error(`Stream closed before matcher succeeded. Received: ${output}`))
|
|
}
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timer)
|
|
emitter.off('data', onData)
|
|
emitter.off('close', onClose)
|
|
emitter.off('end', onClose)
|
|
}
|
|
|
|
emitter.on('data', onData)
|
|
emitter.on('close', onClose)
|
|
emitter.on('end', onClose)
|
|
})
|
|
}
|
|
|
|
describe('session-ftp transport flows', () => {
|
|
let ftpServer
|
|
let ftp
|
|
|
|
beforeEach(async () => {
|
|
ftpServer = await startFtpServer()
|
|
ftp = new Ftp({
|
|
uid: 'ftp-session-ci',
|
|
host: '127.0.0.1',
|
|
port: ftpServer.port,
|
|
user: FTP_USERNAME,
|
|
password: FTP_PASSWORD,
|
|
readyTimeout: 5000
|
|
})
|
|
await ftp.connect(ftp.initOptions)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (ftp) {
|
|
ftp.kill()
|
|
ftp = null
|
|
}
|
|
if (ftpServer) {
|
|
await ftpServer.close()
|
|
ftpServer = null
|
|
}
|
|
})
|
|
|
|
test('connects and performs core file operations against ftp-srv', async () => {
|
|
assert.equal(globalState.getSession(ftp.pid), ftp)
|
|
|
|
await ftp.mkdir('/docs')
|
|
await ftp.writeFile('/docs/hello.txt', 'hello ftp')
|
|
|
|
assert.equal(await ftp.readFile('/docs/hello.txt'), 'hello ftp')
|
|
|
|
const stats = await ftp.stat('/docs/hello.txt')
|
|
assert.equal(stats.isDirectory, false)
|
|
assert.equal(stats.size, 'hello ftp'.length)
|
|
|
|
await ftp.cp('/docs/hello.txt', '/docs-copy.txt')
|
|
assert.equal(await ftp.readFile('/docs-copy.txt'), 'hello ftp')
|
|
|
|
const list = await ftp.list('/docs')
|
|
assert.deepEqual(list.map(item => item.name), ['hello.txt'])
|
|
})
|
|
|
|
test('copies directories recursively and removes them recursively', async () => {
|
|
await ftp.mkdir('/source')
|
|
await ftp.mkdir('/source/nested')
|
|
await ftp.writeFile('/source/root.txt', 'root')
|
|
await ftp.writeFile('/source/nested/child.txt', 'child-data')
|
|
|
|
assert.equal(await ftp.cp('/source', '/copied'), 1)
|
|
assert.equal(await ftp.readFile('/copied/root.txt'), 'root')
|
|
assert.equal(await ftp.readFile('/copied/nested/child.txt'), 'child-data')
|
|
|
|
const size = await ftp.getFolderSize('/copied')
|
|
assert.deepEqual(size, {
|
|
size: 'root'.length + 'child-data'.length,
|
|
count: 2
|
|
})
|
|
|
|
assert.equal(await ftp.rmdir('/source'), 1)
|
|
assert.equal(await ftp.tryStat('/source'), null)
|
|
})
|
|
})
|
|
|
|
describe('session-serial transport flows', () => {
|
|
let term
|
|
|
|
beforeEach(() => {
|
|
require.cache[serialportModulePath].exports = {
|
|
...serialportExports,
|
|
SerialPort: serialportExports.SerialPortMock
|
|
}
|
|
MockBinding.reset()
|
|
MockBinding.createPort(SERIAL_PATH, {
|
|
echo: true,
|
|
record: true
|
|
})
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (term && term.port) {
|
|
const port = term.port
|
|
const closePromise = once(port, 'close').catch(() => {})
|
|
term.kill()
|
|
await closePromise
|
|
term = null
|
|
} else if (term) {
|
|
term.kill()
|
|
term = null
|
|
}
|
|
require.cache[serialportModulePath].exports = serialportExports
|
|
MockBinding.reset()
|
|
})
|
|
|
|
test('creates a serial session and echoes writes through the mock binding', async () => {
|
|
term = await sessionSerial.session({
|
|
uid: 'serial-session-ci',
|
|
path: SERIAL_PATH,
|
|
baudRate: 9600
|
|
})
|
|
|
|
assert.equal(globalState.getSession(term.pid), term)
|
|
|
|
const dataPromise = once(term.port, 'data')
|
|
term.write('ping')
|
|
const [data] = await dataPromise
|
|
|
|
assert.equal(data.toString(), 'ping')
|
|
|
|
const port = term.port
|
|
const closePromise = once(port, 'close')
|
|
term.kill()
|
|
await closePromise
|
|
term = null
|
|
|
|
assert.equal(globalState.getSession('serial-session-ci'), undefined)
|
|
})
|
|
|
|
test('reports serial connectivity through the exported test helper', async () => {
|
|
assert.equal(await sessionSerial.test({
|
|
path: SERIAL_PATH,
|
|
baudRate: 9600
|
|
}), true)
|
|
})
|
|
})
|
|
|
|
describe('session-telnet transport flows', () => {
|
|
let telnetServer
|
|
let term
|
|
|
|
beforeEach(async () => {
|
|
telnetServer = await startTelnetServer()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (term) {
|
|
term.kill()
|
|
term = null
|
|
}
|
|
if (telnetServer) {
|
|
await telnetServer.close()
|
|
telnetServer = null
|
|
}
|
|
})
|
|
|
|
test('connects, authenticates with regex prompts, and exchanges shell data', async () => {
|
|
term = await sessionTelnet.session({
|
|
uid: 'telnet-session-ci',
|
|
host: '127.0.0.1',
|
|
port: telnetServer.port,
|
|
username: TELNET_USERNAME,
|
|
password: TELNET_PASSWORD,
|
|
loginPrompt: '/login[: ]*$/i',
|
|
passwordPrompt: '/password[: ]*$/i',
|
|
readyTimeout: 5000
|
|
})
|
|
term.channel.on('error', () => {})
|
|
term.channel.socket.on('error', () => {})
|
|
|
|
assert.equal(globalState.getSession(term.pid), term)
|
|
|
|
const banner = await waitForText(term.port, (output) => {
|
|
return output.includes('Welcome to electerm')
|
|
})
|
|
assert.match(banner, /welcome to electerm/i)
|
|
|
|
const responsePromise = waitForText(term.port, (output) => {
|
|
return output.includes('echo:status')
|
|
})
|
|
term.write('status\n')
|
|
const response = await responsePromise
|
|
|
|
assert.match(response, /echo:status/)
|
|
|
|
term.resize(132, 43)
|
|
assert.equal(term.channel.options.terminalWidth, 132)
|
|
assert.equal(term.channel.options.terminalHeight, 43)
|
|
|
|
term.kill()
|
|
await telnetServer.waitForIdle()
|
|
term = null
|
|
|
|
assert.equal(globalState.getSession('telnet-session-ci'), undefined)
|
|
})
|
|
})
|