Files
NetMesh/electron/bridges/sftpBridge/scpSessionOps.test.cjs

226 lines
8.0 KiB
JavaScript
Raw Permalink Normal View History

/**
* Integration-style tests: SCP-mode clients registered in sftpClients are
* reachable through the same list/mkdir/write entry points the UI and AI tools use.
*/
"use strict";
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const { createScpBackend, isScpModeClient } = require("./scpBackend.cjs");
const { createFileOpsApi } = require("./fileOps.cjs");
const { SCP_OK, buildFileControlLine } = require("./scpProtocol.cjs");
function createMockStream() {
const ee = new EventEmitter();
ee.writable = true;
ee.readable = true;
ee.stderr = new EventEmitter();
ee.write = (buf, cb) => {
ee.emit("_write", Buffer.from(buf));
if (typeof cb === "function") cb();
return true;
};
ee.end = (cb) => { if (typeof cb === "function") cb(); };
ee.close = () => ee.emit("close");
ee.destroy = () => ee.emit("close");
ee.pushFromRemote = (buf) => ee.emit("data", Buffer.from(buf));
return ee;
}
describe("SCP-mode session ops via fileOps entry points (AI/UI shared path)", () => {
let sftpClients;
let api;
let commands;
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-scp-session-"));
sftpClients = new Map();
commands = [];
const backend = createScpBackend({
exec: async (command) => {
commands.push(command);
if (command.includes("for f in") || command.includes("cd ")) {
const name = Buffer.from("agent.txt").toString("base64");
return {
stdout: `f|-rw-r--r--|4|1700000000|${name}\n`,
stderr: "",
code: 0,
};
}
if (command.includes("mkdir")) return { stdout: "", stderr: "", code: 0 };
if (command.includes("rm ") || command.includes("rmdir")) return { stdout: "", stderr: "", code: 0 };
if (command.includes("mv ")) return { stdout: "", stderr: "", code: 0 };
if (command.includes("$HOME") || command.startsWith("printf")) {
return { stdout: "/home/agent\n", stderr: "", code: 0 };
}
if (command.includes("if [ ! -e")) {
return {
stdout: "f|-rw-r--r--|4|1700000000|/home/agent/agent.txt\n",
stderr: "",
code: 0,
};
}
return { stdout: "", stderr: "", code: 0 };
},
execStream: async (command) => {
commands.push(command);
const stream = createMockStream();
if (command.includes("scp -t")) {
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
stream.on("_write", (buf) => {
const text = buf.toString("utf8");
if (text.startsWith("C") || (buf.length === 1 && buf[0] === 0x00)) {
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
}
});
} else if (command.includes("scp -f")) {
let ackCount = 0;
stream.on("_write", (buf) => {
if (!(buf[0] === SCP_OK && buf.length === 1)) return;
ackCount += 1;
if (ackCount === 1) {
setImmediate(() => {
stream.pushFromRemote(buildFileControlLine({ mode: 0o644, size: 4, name: "agent.txt" }));
});
} else if (ackCount === 2) {
setImmediate(() => {
stream.pushFromRemote(Buffer.concat([Buffer.from("data"), Buffer.from([0x00])]));
});
}
});
}
return stream;
},
});
const client = {
client: { exec: () => {} },
sftp: null,
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
async end() {},
};
sftpClients.set("scp-session-1", client);
// Minimal ctx for createFileOpsApi — only what list/mkdir/rename/delete/write need
api = createFileOpsApi({
get sftpClients() { return sftpClients; },
get electronModule() {
return {
webContents: { fromId: () => ({ send: () => {} }) },
};
},
fileWatcherBridge: { stopWatchersForSession: () => {} },
fs,
path,
Buffer,
console,
setTimeout,
clearTimeout,
jumpConnectionsMap: new Map(),
sftpEncodingState: new Map(),
normalizeEncoding: (e) => e || "utf-8",
isAsciiString: () => true,
requireSftpChannel: async () => { throw new Error("should not use SFTP channel in SCP mode"); },
resolveEncodingForRequest: () => "utf-8",
updateResolvedEncoding: () => "utf-8",
encodePath: (p) => p,
decodeName: (n) => n,
detectEncodingFromList: () => null,
statResultFromAttrs: (a) => a,
normalizeRemotePathString: async (_c, p) => p,
collectReadable: async () => Buffer.alloc(0),
writeToWritable: async () => {},
throwIfAborted: () => {},
pipeStreams: async () => {},
ensureRemoteDirForSession: async () => true,
removeRemotePathInternal: async () => {},
renameRemotePath: async () => {},
realpathAsync: async () => "/",
statAsync: async () => ({}),
readdirAsync: async () => [],
mkdirAsync: async () => {},
rmdirAsync: async () => {},
unlinkAsync: async () => {},
openFileAsync: async () => ({}),
writeFileChunkAsync: async () => {},
closeFileAsync: async () => {},
createAbortError: (s, m) => new Error(m),
copySftpEncodingState: () => {},
clearSftpEncodingState: () => {},
safeSend: () => {},
tempDirBridge: { getTempFilePath: (n) => path.join(tmpDir, n) },
randomUUID: () => "test-uuid",
});
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it("marks clients as SCP-mode", () => {
assert.equal(isScpModeClient(sftpClients.get("scp-session-1")), true);
});
it("list works for SCP-mode session id (AI sftp.list path)", async () => {
const entries = await api.listSftp(null, { sftpId: "scp-session-1", path: "/home/agent" });
assert.equal(entries.length, 1);
assert.equal(entries[0].name, "agent.txt");
assert.ok(commands.some((c) => c.includes("cd ") || c.includes("for f in")));
});
it("mkdir write-class op works for SCP-mode session (AI sftp.mkdir path)", async () => {
const ok = await api.mkdirSftp(null, { sftpId: "scp-session-1", path: "/home/agent/newdir" });
assert.equal(ok, true);
assert.ok(commands.some((c) => c.includes("mkdir") && c.includes("/home/agent/newdir")));
});
it("rename and delete work for SCP-mode session", async () => {
await api.renameSftp(null, {
sftpId: "scp-session-1",
oldPath: "/home/agent/a",
newPath: "/home/agent/b",
});
await api.deleteSftp(null, { sftpId: "scp-session-1", path: "/home/agent/b" });
assert.ok(commands.some((c) => c.includes("mv --")));
assert.ok(commands.some((c) => c.includes("rm ")));
});
it("write + homeDir work for SCP-mode session", async () => {
await api.writeSftp(null, {
sftpId: "scp-session-1",
path: "/home/agent/out.txt",
content: "hi\n",
});
assert.ok(commands.some((c) => c.includes("scp -t")));
const home = await api.getSftpHomeDir(null, { sftpId: "scp-session-1" });
assert.equal(home.success, true);
assert.equal(home.homeDir, "/home/agent");
});
it("capability catalog still exposes stable sftp.* CLI verbs", () => {
const { SFTP_CAPABILITIES } = require("../../capabilities/catalog/sftp.cjs");
const ids = SFTP_CAPABILITIES.map((c) => c.id);
for (const id of [
"sftp.list",
"sftp.mkdir",
"sftp.write",
"sftp.upload",
"sftp.download",
"sftp.delete",
"sftp.rename",
]) {
assert.ok(ids.includes(id), `missing capability ${id}`);
}
const listCap = SFTP_CAPABILITIES.find((c) => c.id === "sftp.list");
assert.deepEqual(listCap.surfaces.cli.command, ["sftp", "list"]);
assert.match(listCap.description, /SCP-mode/i);
});
});