99 lines
4.0 KiB
JavaScript
99 lines
4.0 KiB
JavaScript
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/);
|
|
}
|
|
});
|