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
251 lines
9.7 KiB
JavaScript
251 lines
9.7 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
}
|