import test from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); // SshService resolves ssh2 lazily, so replacing the cached module is enough to // drive a real connection lifecycle without a server. const ssh2Path = require.resolve("ssh2"); const realSsh2 = require("ssh2"); function withFakeSsh2(Client, run) { require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: { ...realSsh2, Client } }; try { return run(); } finally { require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: realSsh2 }; } } const { SshService } = require("../src/main/ssh-service.cjs"); function store(server = {}) { return { getServer: () => ({ id: "unraid", host: "tower", port: 22, username: "root", authType: "password", basePath: "/mnt/user/appdata", hostFingerprint: "SHA256:trusted", ...server }), getServerCredentials: () => ({ password: "secret", passphrase: "" }), }; } test("a connection that fails twice rejects once and never terminates the process", async () => { class DoubleFailingClient extends EventEmitter { connect() { setImmediate(() => this.emit("error", Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }))); } end() { // The socket resets shortly after teardown. An unhandled 'error' event on // an EventEmitter takes the whole main process down. setImmediate(() => this.emit("error", new Error("read ECONNRESET"))); } } const service = withFakeSsh2(DoubleFailingClient, () => new SshService({ store: store(), diagnostics: null })); await assert.rejects( () => withFakeSsh2(DoubleFailingClient, () => service.exec("unraid", "true")), (error) => { assert.equal(error.code, "ECONNREFUSED"); assert.match(error.message, /SSH connection failed/); return true; }, ); // Give the delayed teardown error time to land while the test is still running. await new Promise((resolve) => setTimeout(resolve, 50)); }); test("a host key that does not match the trusted fingerprint is reported as an identity change", async () => { class MismatchingClient extends EventEmitter { connect(options) { options.hostVerifier(Buffer.from("a different host key")); setImmediate(() => this.emit("error", new Error("handshake failed"))); } end() {} } const service = withFakeSsh2(MismatchingClient, () => new SshService({ store: store(), diagnostics: null })); await assert.rejects( () => withFakeSsh2(MismatchingClient, () => service.exec("unraid", "true")), (error) => { assert.equal(error.code, "SSH_HOST_KEY_MISMATCH"); assert.match(error.message, /SSH host identity changed/); assert.equal(error.expectedFingerprint, "SHA256:trusted"); assert.ok(error.observedFingerprint.startsWith("SHA256:")); return true; }, ); }); test("running a command requires a trusted host fingerprint", async () => { const service = new SshService({ store: store({ hostFingerprint: "" }), diagnostics: null }); await assert.rejects(() => service.exec("unraid", "true"), (error) => { assert.equal(error.code, "SSH_HOST_NOT_TRUSTED"); return true; }); await assert.rejects(() => service.uploadBuffer("unraid", "/mnt/user/appdata/x", "data"), (error) => { assert.equal(error.code, "SSH_HOST_NOT_TRUSTED"); return true; }); }); test("a remote upload path may not escape into an arbitrary location", () => { const service = new SshService({ store: store(), diagnostics: null }); assert.equal(service.ensureUploadTarget("/mnt/user/appdata/app/file.tar"), "/mnt/user/appdata/app/file.tar"); assert.equal(service.ensureUploadTarget("\\mnt\\user\\appdata\\app"), "/mnt/user/appdata/app"); for (const value of ["relative/path", "/mnt/../etc/passwd", "/mnt/user/../../etc", "", null]) { assert.throws(() => service.ensureUploadTarget(value), /absolute safe Unix path/); } });