Files
ForgeFlow/tests/ssh-service.test.mjs
T
NuklearRabbit d47c7b5e41
ForgeFlow quality gate / secret-scan (push) Failing after 32s
ForgeFlow quality gate / quality (push) Failing after 0s
feat: add safe Gitea sync and signed updates
2026-08-27 00:38:58 +02:00

158 lines
9.3 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);
const previewBound = await service.connectionOptions(
{ id: 'one', host: 'server', username: 'root', authType: 'password' },
{ expectedFingerprint: fingerprint },
);
assert.equal(previewBound.options.hostVerifier(key), true);
assert.equal(previewBound.options.hostVerifier(Buffer.from('changed')), false);
});
test("SSH host fingerprint preview rejects the handshake before credentials are requested", async () => {
const key = Buffer.from("untrusted-server-key");
let connectedOptions = null;
class ProbeClient extends EventEmitter {
connect(options) {
connectedOptions = options;
assert.equal(options.hostVerifier(key), false);
queueMicrotask(() => this.emit("error", Object.assign(new Error("host rejected"), { code: "HOST_VERIFIER_REJECTED" })));
}
end() {}
}
const store = {
getServer: () => ({ id: "server", name: "Unraid", host: "192.0.2.10", port: 2222, username: "root", authType: "password" }),
getServerCredentials: () => { throw new Error("credentials must not be read during a fingerprint preview"); },
};
const service = new SshService({ store, diagnostics: null, clientFactory: () => ProbeClient });
const result = await service.probeHostFingerprint("server");
assert.equal(result.fingerprint, fingerprintKey(key));
assert.deepEqual(result.server, { id: "server", name: "Unraid", host: "192.0.2.10", port: 2222 });
assert.equal("password" in connectedOptions, false);
assert.equal("privateKey" in connectedOptions, false);
});
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 });
});