129 lines
7.8 KiB
JavaScript
129 lines
7.8 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { EventEmitter } from "node:events";
|
|
import { createRequire } from "node:module";
|
|
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { SshService, parseCapabilityOutput, shellQuote, fingerprintKey } = require("../src/main/ssh-service.cjs");
|
|
|
|
test("SSH capability parsing keeps Git optional and reports deployment prerequisites separately", () => {
|
|
const b64 = (value) => Buffer.from(value).toString("base64");
|
|
const parsed = parseCapabilityOutput(`noise\n__FORGEFLOW_SERVER_TEST__\nplatform=${b64("Linux Unraid")}\ndocker=true\ndockerReady=true\ncompose=true\ncomposeVersion=${b64("Docker Compose version v2.40.0")}\ngit=false\ntar=true\nchecksum=true\nbaseWritable=true\n`);
|
|
assert.equal(parsed.dockerReady, true);
|
|
assert.equal(parsed.compose, true);
|
|
assert.equal(parsed.git, false);
|
|
assert.equal(parsed.tar, true);
|
|
assert.equal(parsed.checksum, true);
|
|
assert.equal(parsed.baseWritable, true);
|
|
});
|
|
|
|
test("SSH execution rejects truncated output instead of using an incomplete inventory", async () => {
|
|
const service = new SshService({ store: {}, diagnostics: null });
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
const client = {
|
|
exec(_command, callback) {
|
|
callback(null, stream);
|
|
queueMicrotask(() => {
|
|
stream.emit("data", Buffer.from("x".repeat(64)));
|
|
stream.emit("close", 0, null);
|
|
});
|
|
},
|
|
};
|
|
await assert.rejects(
|
|
service.execClient(client, "inventory", { maxOutput: 16, timeout: 1_000 }),
|
|
(error) => error?.code === "SSH_OUTPUT_TRUNCATED" && /incomplete result/.test(error.message),
|
|
);
|
|
});
|
|
|
|
test("SSH helpers quote shell values, fingerprint keys and parse absent capability markers", () => {
|
|
assert.equal(shellQuote("it's safe"), "'it'\\''s safe'");
|
|
assert.equal(fingerprintKey(Buffer.from('key')), fingerprintKey('key'));
|
|
assert.match(fingerprintKey('key'), /^SHA256:/);
|
|
assert.deepEqual(parseCapabilityOutput('plain server banner'), {
|
|
platform: 'plain server banner', docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false
|
|
});
|
|
});
|
|
|
|
test("server validation handles password and missing or non-file private keys", async (context) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-ssh-'));
|
|
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
|
|
const service = new SshService({ store: {}, diagnostics: null });
|
|
assert.deepEqual(await service.validateServerConfiguration({ authType: 'password' }), { valid: true, method: 'password' });
|
|
await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: '' }), /select a private key/i);
|
|
await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: path.join(root, 'missing') }), (error) => error.code === 'SSH_PRIVATE_KEY_NOT_FOUND');
|
|
await mkdir(path.join(root, 'directory'));
|
|
await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: path.join(root, 'directory') }), (error) => error.code === 'SSH_PRIVATE_KEY_NOT_FOUND');
|
|
});
|
|
|
|
test("connection options enforce host identity and support password credentials", async () => {
|
|
const key = Buffer.from('server-key');
|
|
const fingerprint = fingerprintKey(key);
|
|
const store = { getServerCredentials: () => ({ password: 'secret' }) };
|
|
const service = new SshService({ store, diagnostics: null });
|
|
const trusted = await service.connectionOptions({ id: 'one', host: 'server', port: 2222, username: 'root', authType: 'password', hostFingerprint: fingerprint });
|
|
assert.equal(trusted.options.password, 'secret');
|
|
assert.equal(trusted.options.port, 2222);
|
|
assert.equal(trusted.options.hostVerifier(key), true);
|
|
assert.equal(trusted.getObservedFingerprint(), fingerprint);
|
|
assert.equal(trusted.options.hostVerifier(Buffer.from('changed')), false);
|
|
const firstUse = await service.connectionOptions({ id: 'one', host: 'server', username: 'root', authType: 'password' }, { trustOnFirstUse: true });
|
|
assert.equal(firstUse.options.port, 22);
|
|
assert.equal(firstUse.options.hostVerifier(key), true);
|
|
});
|
|
|
|
test("connection options report unreadable private keys without leaking credentials", async () => {
|
|
const service = new SshService({ store: { getServerCredentials: () => ({ passphrase: 'secret' }) }, diagnostics: null });
|
|
await assert.rejects(
|
|
service.connectionOptions({ id: 'key', host: 'server', username: 'root', authType: 'privateKey', privateKeyPath: 'Z:/missing/key' }),
|
|
(error) => error.code === 'SSH_PRIVATE_KEY_READ_FAILED' && !error.message.includes('secret')
|
|
);
|
|
});
|
|
|
|
test("SSH execution distinguishes startup errors, command failures, success and timeout", async () => {
|
|
const service = new SshService({ store: {}, diagnostics: null });
|
|
const clientFor = (start) => ({ exec(_command, callback) { start(callback); } });
|
|
await assert.rejects(service.execClient(clientFor((callback) => callback(new Error('exec unavailable'))), 'x'), /exec unavailable/);
|
|
|
|
const commandClient = clientFor((callback) => {
|
|
const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream);
|
|
queueMicrotask(() => { stream.stderr.emit('data', Buffer.from('permission denied')); stream.emit('close', 23, 'TERM'); });
|
|
});
|
|
await assert.rejects(service.execClient(commandClient, 'x'), (error) => error.code === 'SSH_COMMAND_FAILED' && error.exitCode === 23 && error.signal === 'TERM');
|
|
|
|
const successClient = clientFor((callback) => {
|
|
const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream);
|
|
queueMicrotask(() => { stream.emit('data', Buffer.from('ok')); stream.stderr.emit('data', Buffer.from('warning')); stream.emit('close', 0, null); });
|
|
});
|
|
assert.deepEqual(await service.execClient(successClient, 'x'), { stdout: 'ok', stderr: 'warning', exitCode: 0, truncated: false });
|
|
|
|
const hangingClient = clientFor((callback) => { const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream); });
|
|
await assert.rejects(service.execClient(hangingClient, 'x', { timeout: 5 }), /timed out/i);
|
|
});
|
|
|
|
test("upload and execution reject unsafe paths and untrusted hosts", async () => {
|
|
const service = new SshService({ store: { getServer: () => ({ id: 'one' }) }, diagnostics: null });
|
|
assert.equal(service.ensureUploadTarget('\\srv\\apps\\file'), '/srv/apps/file');
|
|
for (const target of ['', 'relative/file', '/srv/../secret', `/srv/${String.fromCharCode(0)}bad`]) {
|
|
assert.throws(() => service.ensureUploadTarget(target), /absolute safe Unix path/i);
|
|
}
|
|
await assert.rejects(service.withSftp('one', '/srv/file', () => {}), (error) => error.code === 'SSH_HOST_NOT_TRUSTED');
|
|
await assert.rejects(service.exec('one', 'true'), (error) => error.code === 'SSH_HOST_NOT_TRUSTED');
|
|
});
|
|
|
|
test("uploadFile rejects directories before connecting", async (context) => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-upload-'));
|
|
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
|
|
const service = new SshService({ store: {}, diagnostics: null });
|
|
await assert.rejects(service.uploadFile('one', root, '/srv/file'), /not a file/i);
|
|
const file = path.join(root, 'file');
|
|
await writeFile(file, 'content');
|
|
service.withSftp = async (_id, remotePath, action) => action({ fastPut(_local, _target, options, callback) { options.step(7, 7, 7); callback(null); } }, remotePath);
|
|
let progress = null;
|
|
assert.deepEqual(await service.uploadFile('one', file, '/srv/file', { onProgress: (value) => { progress = value; } }), { remotePath: '/srv/file', size: 7 });
|
|
assert.deepEqual(progress, { transferred: 7, total: 7 });
|
|
});
|