[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
250
electron/bridges/sftpBridge/scpIntegration.real.test.cjs
Normal file
250
electron/bridges/sftpBridge/scpIntegration.real.test.cjs
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Real OpenSSH SCP integration tests against a live host.
|
||||
*
|
||||
* Gated by env (or defaults for local interactive IT runs):
|
||||
* NETCATTY_SCP_IT_HOST, NETCATTY_SCP_IT_USER, NETCATTY_SCP_IT_PASSWORD
|
||||
* NETCATTY_SCP_IT_PORT (optional, default 22)
|
||||
* NETCATTY_SCP_IT=1 to force-enable when password is set
|
||||
*
|
||||
* When host/password are unset, the suite skips (CI-safe).
|
||||
* When set, failures fail the suite — do not soft-skip mid-run.
|
||||
*
|
||||
* Credentials must NOT be hard-coded here; pass via env for the run.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
const {
|
||||
createScpBackend,
|
||||
createSshExecAdapters,
|
||||
} = require("./scpBackend.cjs");
|
||||
|
||||
const HOST = process.env.NETCATTY_SCP_IT_HOST || "";
|
||||
const USER = process.env.NETCATTY_SCP_IT_USER || "root";
|
||||
const PASSWORD = process.env.NETCATTY_SCP_IT_PASSWORD || "";
|
||||
const PORT = Number(process.env.NETCATTY_SCP_IT_PORT || 22);
|
||||
const ENABLED = process.env.NETCATTY_SCP_IT === "1"
|
||||
|| (HOST && PASSWORD);
|
||||
|
||||
const remotePrefix = `/tmp/netcatty-scp-it-${Date.now()}-${process.pid}`;
|
||||
|
||||
function connectSsh() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new SSHClient();
|
||||
const timer = setTimeout(() => {
|
||||
try { client.end(); } catch { /* ignore */ }
|
||||
reject(new Error(`SSH connect timeout to ${HOST}:${PORT}`));
|
||||
}, 20000);
|
||||
client
|
||||
.on("ready", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(client);
|
||||
})
|
||||
.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
})
|
||||
.connect({
|
||||
host: HOST,
|
||||
port: PORT,
|
||||
username: USER,
|
||||
password: PASSWORD,
|
||||
readyTimeout: 15000,
|
||||
tryKeyboard: true,
|
||||
// Accept first-time host keys for IT environments
|
||||
hostVerifier: () => true,
|
||||
});
|
||||
client.on("keyboard-interactive", (_name, _instructions, _lang, prompts, finish) => {
|
||||
finish(prompts.map(() => PASSWORD));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const suite = ENABLED ? describe : describe.skip;
|
||||
|
||||
suite("real OpenSSH SCP integration (shipped scpBackend)", () => {
|
||||
let ssh;
|
||||
let backend;
|
||||
let localTmp;
|
||||
|
||||
before(async () => {
|
||||
assert.ok(HOST, "NETCATTY_SCP_IT_HOST required");
|
||||
assert.ok(PASSWORD, "NETCATTY_SCP_IT_PASSWORD required");
|
||||
localTmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-scp-it-local-"));
|
||||
ssh = await connectSsh();
|
||||
const adapters = createSshExecAdapters(ssh);
|
||||
backend = createScpBackend(adapters);
|
||||
await backend.mkdir(remotePrefix, { recursive: true });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
try {
|
||||
if (backend) await backend.remove(remotePrefix, { recursive: true });
|
||||
} catch (err) {
|
||||
console.warn("[scp-it] cleanup remove failed:", err.message);
|
||||
}
|
||||
try {
|
||||
if (ssh) ssh.end();
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
if (localTmp) fs.rmSync(localTmp, { recursive: true, force: true });
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
it("homeDir returns a non-empty path", async () => {
|
||||
const home = await backend.homeDir();
|
||||
assert.ok(home && home.length > 0, `homeDir empty: ${home}`);
|
||||
console.log("[scp-it] homeDir=", home);
|
||||
});
|
||||
|
||||
it("mkdir + list shows created directory", async () => {
|
||||
const sub = `${remotePrefix}/subdir`;
|
||||
await backend.mkdir(sub, { recursive: true });
|
||||
const entries = await backend.list(remotePrefix);
|
||||
const names = entries.map((e) => e.name);
|
||||
console.log("[scp-it] list after mkdir:", names);
|
||||
assert.ok(names.includes("subdir"), `expected subdir in ${JSON.stringify(names)}`);
|
||||
const dir = entries.find((e) => e.name === "subdir");
|
||||
assert.equal(dir.type, "directory");
|
||||
});
|
||||
|
||||
it("upload then download is byte-identical for binary payload", async () => {
|
||||
const payload = crypto.randomBytes(64 * 1024 + 17); // not power-of-two edge
|
||||
const localUp = path.join(localTmp, "payload.bin");
|
||||
const localDown = path.join(localTmp, "payload.down.bin");
|
||||
fs.writeFileSync(localUp, payload);
|
||||
const remoteFile = `${remotePrefix}/payload.bin`;
|
||||
|
||||
const progress = [];
|
||||
await backend.uploadFile(localUp, remoteFile, {
|
||||
onProgress: (t, total) => progress.push([t, total]),
|
||||
});
|
||||
assert.ok(progress.length > 0, "expected upload progress callbacks");
|
||||
|
||||
const entries = await backend.list(remotePrefix);
|
||||
assert.ok(entries.some((e) => e.name === "payload.bin"), "list missing uploaded file");
|
||||
|
||||
await backend.downloadFile(remoteFile, localDown, {
|
||||
fileSize: payload.length,
|
||||
});
|
||||
const down = fs.readFileSync(localDown);
|
||||
const upHash = crypto.createHash("sha256").update(payload).digest("hex");
|
||||
const downHash = crypto.createHash("sha256").update(down).digest("hex");
|
||||
console.log("[scp-it] upload/download sha256", upHash, downHash, "bytes", down.length);
|
||||
assert.equal(down.length, payload.length);
|
||||
assert.equal(downHash, upHash);
|
||||
});
|
||||
|
||||
it("uploadBuffer write path works for small text", async () => {
|
||||
const remoteFile = `${remotePrefix}/note.txt`;
|
||||
const body = Buffer.from("hello-scp-integration\nline2\n", "utf8");
|
||||
await backend.writeFile(remoteFile, body, { mode: 0o0644 });
|
||||
const read = await backend.readFile(remoteFile);
|
||||
assert.equal(read.toString("utf8"), body.toString("utf8"));
|
||||
});
|
||||
|
||||
it("rename then delete leaves expected tree", async () => {
|
||||
const a = `${remotePrefix}/rename-a.txt`;
|
||||
const b = `${remotePrefix}/rename-b.txt`;
|
||||
await backend.writeFile(a, Buffer.from("rename-me\n"));
|
||||
await backend.rename(a, b);
|
||||
let entries = await backend.list(remotePrefix);
|
||||
let names = entries.map((e) => e.name);
|
||||
assert.ok(names.includes("rename-b.txt"), `after rename: ${JSON.stringify(names)}`);
|
||||
assert.ok(!names.includes("rename-a.txt"), `old name still present: ${JSON.stringify(names)}`);
|
||||
|
||||
await backend.remove(b, { recursive: false });
|
||||
entries = await backend.list(remotePrefix);
|
||||
names = entries.map((e) => e.name);
|
||||
assert.ok(!names.includes("rename-b.txt"), `delete failed: ${JSON.stringify(names)}`);
|
||||
console.log("[scp-it] final names after rename/delete:", names);
|
||||
});
|
||||
|
||||
it("stat reports size for uploaded file", async () => {
|
||||
const remoteFile = `${remotePrefix}/payload.bin`;
|
||||
// may already exist from earlier test; recreate if needed
|
||||
try {
|
||||
const st = await backend.stat(remoteFile);
|
||||
assert.ok(st.size > 0, `stat size ${st.size}`);
|
||||
assert.equal(st.isDirectory, false);
|
||||
console.log("[scp-it] stat", st);
|
||||
} catch {
|
||||
const localUp = path.join(localTmp, "stat.bin");
|
||||
fs.writeFileSync(localUp, Buffer.from("stat-payload"));
|
||||
await backend.uploadFile(localUp, remoteFile);
|
||||
const st = await backend.stat(remoteFile);
|
||||
assert.equal(st.size, 12);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles spaces in remote path for mkdir/upload/list/download/delete", async () => {
|
||||
const dir = `${remotePrefix}/dir with spaces`;
|
||||
const remoteFile = `${dir}/file with spaces.bin`;
|
||||
const payload = Buffer.from("space-path-payload-ok");
|
||||
const localUp = path.join(localTmp, "spaces-up.bin");
|
||||
const localDown = path.join(localTmp, "spaces-down.bin");
|
||||
fs.writeFileSync(localUp, payload);
|
||||
|
||||
await backend.mkdir(dir, { recursive: true });
|
||||
await backend.uploadFile(localUp, remoteFile);
|
||||
const entries = await backend.list(dir);
|
||||
const names = entries.map((e) => e.name);
|
||||
console.log("[scp-it] space path list:", names);
|
||||
assert.ok(names.includes("file with spaces.bin"), `list: ${JSON.stringify(names)}`);
|
||||
|
||||
await backend.downloadFile(remoteFile, localDown);
|
||||
assert.equal(fs.readFileSync(localDown).toString(), payload.toString());
|
||||
|
||||
await backend.remove(remoteFile);
|
||||
const after = (await backend.list(dir)).map((e) => e.name);
|
||||
assert.ok(!after.includes("file with spaces.bin"), `still present: ${JSON.stringify(after)}`);
|
||||
});
|
||||
|
||||
it("empty file upload/download round-trips", async () => {
|
||||
const remoteFile = `${remotePrefix}/empty.dat`;
|
||||
const localUp = path.join(localTmp, "empty.dat");
|
||||
const localDown = path.join(localTmp, "empty.down.dat");
|
||||
fs.writeFileSync(localUp, Buffer.alloc(0));
|
||||
await backend.uploadFile(localUp, remoteFile);
|
||||
await backend.downloadFile(remoteFile, localDown, { fileSize: 0 });
|
||||
assert.equal(fs.readFileSync(localDown).length, 0);
|
||||
const st = await backend.stat(remoteFile);
|
||||
assert.equal(st.size, 0);
|
||||
});
|
||||
|
||||
it("directory symlink linkTarget is directory when target is a dir", async () => {
|
||||
const realDir = `${remotePrefix}/real-target-dir`;
|
||||
const linkPath = `${remotePrefix}/link-to-dir`;
|
||||
await backend.mkdir(realDir, { recursive: true });
|
||||
// Create symlink via shell (not part of SCP wire, but list must resolve it)
|
||||
const { exec } = createSshExecAdapters(ssh);
|
||||
const { shellQuote } = require("./scpShell.cjs");
|
||||
const ln = await exec(`ln -sfn ${shellQuote(realDir)} ${shellQuote(linkPath)}`);
|
||||
assert.equal(ln.code, 0, `ln failed: ${ln.stderr}`);
|
||||
const entries = await backend.list(remotePrefix);
|
||||
const link = entries.find((e) => e.name === "link-to-dir");
|
||||
console.log("[scp-it] symlink entry:", link);
|
||||
assert.ok(link, "symlink missing from list");
|
||||
assert.equal(link.type, "symlink");
|
||||
assert.equal(link.linkTarget, "directory");
|
||||
});
|
||||
});
|
||||
|
||||
// Always-visible marker so logs show skip reason when env unset
|
||||
if (!ENABLED) {
|
||||
describe("real OpenSSH SCP integration (skipped — set NETCATTY_SCP_IT_HOST/PASSWORD)", () => {
|
||||
it("documents skip condition", () => {
|
||||
console.log(
|
||||
"[scp-it] skipped: export NETCATTY_SCP_IT_HOST NETCATTY_SCP_IT_USER NETCATTY_SCP_IT_PASSWORD (or NETCATTY_SCP_IT=1 with those set)",
|
||||
);
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user