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 }); });