[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:
514
electron/bridges/sftpBridge/fileOps.test.cjs
Normal file
514
electron/bridges/sftpBridge/fileOps.test.cjs
Normal file
@@ -0,0 +1,514 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
|
||||
const { createFileOpsApi } = require("./fileOps.cjs");
|
||||
|
||||
test("home discovery accepts a virtual SFTP root when SSH exec is unavailable", async () => {
|
||||
const channel = {};
|
||||
const listed = [];
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["jumpserver", { sftp: channel }]]),
|
||||
throwIfAborted() {},
|
||||
requireSftpChannel: async () => channel,
|
||||
realpathAsync: async (resolvedChannel, remotePath) => {
|
||||
assert.equal(resolvedChannel, channel);
|
||||
assert.equal(remotePath, ".");
|
||||
return "/";
|
||||
},
|
||||
readdirAsync: async (resolvedChannel, remotePath) => {
|
||||
listed.push([resolvedChannel, remotePath]);
|
||||
return [{ filename: "data" }];
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.getSftpHomeDir(null, { sftpId: "jumpserver" });
|
||||
|
||||
assert.deepEqual(result, { success: true, homeDir: "/" });
|
||||
assert.deepEqual(listed, [[channel, "/"]]);
|
||||
});
|
||||
|
||||
test("home discovery rejects non-listable root so candidate probing can run", async () => {
|
||||
const channel = {};
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["restricted", { sftp: channel }]]),
|
||||
throwIfAborted() {},
|
||||
requireSftpChannel: async () => channel,
|
||||
realpathAsync: async () => "/",
|
||||
readdirAsync: async () => {
|
||||
const error = new Error("Permission denied");
|
||||
error.code = "EACCES";
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.getSftpHomeDir(null, { sftpId: "restricted" });
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error || "", /Could not determine home directory/);
|
||||
});
|
||||
|
||||
test("home discovery still accepts non-root realpath without listing", async () => {
|
||||
const channel = {};
|
||||
let readdirCalls = 0;
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["normal", { sftp: channel }]]),
|
||||
throwIfAborted() {},
|
||||
requireSftpChannel: async () => channel,
|
||||
realpathAsync: async () => "/home/deploy",
|
||||
readdirAsync: async () => {
|
||||
readdirCalls += 1;
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.getSftpHomeDir(null, { sftpId: "normal" });
|
||||
|
||||
assert.deepEqual(result, { success: true, homeDir: "/home/deploy" });
|
||||
assert.equal(readdirCalls, 0);
|
||||
});
|
||||
|
||||
test("statSftp follows symlinks and reports target size for resume sizing", async () => {
|
||||
const channel = { stat() {}, lstat() {} };
|
||||
let lstatCalls = 0;
|
||||
let statCalls = 0;
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
path: require("node:path"),
|
||||
requireSftpChannel: async () => channel,
|
||||
resolveEncodingForRequest: () => "utf-8",
|
||||
encodePath: (remotePath) => remotePath,
|
||||
lstatAsync: async () => {
|
||||
lstatCalls += 1;
|
||||
return {
|
||||
size: 11,
|
||||
mode: 0o120777,
|
||||
mtime: 10,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => true,
|
||||
};
|
||||
},
|
||||
statAsync: async () => {
|
||||
statCalls += 1;
|
||||
return {
|
||||
size: 42,
|
||||
mode: 0o100644,
|
||||
mtime: 20,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
};
|
||||
},
|
||||
statResultFromAttrs: (attrs) => ({
|
||||
size: attrs.size,
|
||||
modifyTime: attrs.mtime * 1000,
|
||||
mode: attrs.mode,
|
||||
isDirectory: attrs.isDirectory(),
|
||||
isSymbolicLink: attrs.isSymbolicLink(),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await api.statSftp(null, {
|
||||
sftpId: "sftp-1",
|
||||
path: "/usr/local/bin/tool",
|
||||
});
|
||||
|
||||
assert.equal(statCalls, 1);
|
||||
assert.equal(lstatCalls, 0, "shared stat must follow for resume/sizing");
|
||||
assert.equal(result.type, "file");
|
||||
assert.equal(result.size, 42);
|
||||
});
|
||||
|
||||
test("lstatSftp classifies symlinks without following the target", async () => {
|
||||
const channel = { lstat() {} };
|
||||
let lstatCalls = 0;
|
||||
let statCalls = 0;
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
path: require("node:path"),
|
||||
requireSftpChannel: async () => channel,
|
||||
resolveEncodingForRequest: () => "utf-8",
|
||||
encodePath: (remotePath) => remotePath,
|
||||
lstatAsync: async () => {
|
||||
lstatCalls += 1;
|
||||
return {
|
||||
size: 11,
|
||||
mode: 0o120777,
|
||||
mtime: 10,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => true,
|
||||
};
|
||||
},
|
||||
statAsync: async () => {
|
||||
statCalls += 1;
|
||||
return {
|
||||
size: 42,
|
||||
mode: 0o100644,
|
||||
mtime: 20,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
};
|
||||
},
|
||||
statResultFromAttrs: (attrs) => ({
|
||||
size: attrs.size,
|
||||
modifyTime: attrs.mtime * 1000,
|
||||
mode: attrs.mode,
|
||||
isDirectory: attrs.isDirectory(),
|
||||
isSymbolicLink: attrs.isSymbolicLink(),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await api.lstatSftp(null, {
|
||||
sftpId: "sftp-1",
|
||||
path: "/usr/local/bin/tool",
|
||||
});
|
||||
|
||||
assert.equal(lstatCalls, 1);
|
||||
assert.equal(statCalls, 0, "must not follow the symlink with STAT");
|
||||
assert.equal(result.type, "symlink");
|
||||
assert.equal(result.size, 11);
|
||||
});
|
||||
|
||||
test("lstatSftp refuses followed STAT when LSTAT is unsupported", async () => {
|
||||
const channel = { lstat() {}, stat() {} };
|
||||
let statCalls = 0;
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
path: require("node:path"),
|
||||
requireSftpChannel: async () => channel,
|
||||
resolveEncodingForRequest: () => "utf-8",
|
||||
encodePath: (remotePath) => remotePath,
|
||||
lstatAsync: async () => {
|
||||
const error = new Error("SSH_FX_OP_UNSUPPORTED");
|
||||
error.code = 8;
|
||||
throw error;
|
||||
},
|
||||
statAsync: async () => {
|
||||
statCalls += 1;
|
||||
return {
|
||||
size: 42,
|
||||
mode: 0o100644,
|
||||
mtime: 20,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
};
|
||||
},
|
||||
statResultFromAttrs: (attrs) => ({
|
||||
size: attrs.size,
|
||||
modifyTime: attrs.mtime * 1000,
|
||||
mode: attrs.mode,
|
||||
isDirectory: attrs.isDirectory(),
|
||||
isSymbolicLink: attrs.isSymbolicLink(),
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => api.lstatSftp(null, { sftpId: "sftp-1", path: "/usr/local/bin/tool" }),
|
||||
(error) => {
|
||||
assert.equal(error.code, "ENOTSUP");
|
||||
assert.equal(error.lstatUnavailable, true);
|
||||
assert.match(String(error.message), /does not support LSTAT/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(statCalls, 0, "must not classify via followed STAT");
|
||||
});
|
||||
|
||||
test("lstatSftp returns null for SSH_FX_NO_SUCH_FILE even with a localized message", async () => {
|
||||
const channel = { lstat() {} };
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
requireSftpChannel: async () => channel,
|
||||
resolveEncodingForRequest: () => "utf-8",
|
||||
encodePath: (remotePath) => remotePath,
|
||||
lstatAsync: async () => {
|
||||
const error = new Error("File not found");
|
||||
error.code = 2;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await api.lstatSftp(null, {
|
||||
sftpId: "sftp-1",
|
||||
path: "/usr/local/bin/new-file.sh",
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("lstatSftp still throws permission errors on an existing path", async () => {
|
||||
const channel = { lstat() {} };
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
requireSftpChannel: async () => channel,
|
||||
resolveEncodingForRequest: () => "utf-8",
|
||||
encodePath: (remotePath) => remotePath,
|
||||
lstatAsync: async () => {
|
||||
const error = new Error("Permission denied");
|
||||
error.code = "EACCES";
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => api.lstatSftp(null, { sftpId: "sftp-1", path: "/root/secret" }),
|
||||
(error) => {
|
||||
assert.equal(error.code, "EACCES");
|
||||
assert.match(String(error.message), /Permission denied/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("lstatSftp refuses a channel without native LSTAT", async () => {
|
||||
const channel = { stat() {} };
|
||||
let lstatCalls = 0;
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
requireSftpChannel: async () => channel,
|
||||
lstatAsync: async () => { lstatCalls += 1; },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => api.lstatSftp(null, { sftpId: "sftp-1", path: "/usr/local/bin/tool" }),
|
||||
(error) => {
|
||||
assert.equal(error.code, "ENOTSUP");
|
||||
assert.equal(error.lstatUnavailable, true);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(lstatCalls, 0, "must not call the followed-stat fallback");
|
||||
});
|
||||
|
||||
test("expected symlink delete refuses a replacement file", async () => {
|
||||
const channel = { lstat() {} };
|
||||
let unlinkCalls = 0;
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
throwIfAborted() {},
|
||||
requireSftpChannel: async () => channel,
|
||||
resolveEncodingForRequest: () => "utf-8",
|
||||
encodePath: (remotePath) => remotePath,
|
||||
lstatAsync: async () => ({
|
||||
size: 4,
|
||||
mode: 0o100644,
|
||||
mtime: 20,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
}),
|
||||
statResultFromAttrs: (attrs) => ({
|
||||
size: attrs.size,
|
||||
modifyTime: attrs.mtime * 1000,
|
||||
mode: attrs.mode,
|
||||
isDirectory: attrs.isDirectory(),
|
||||
isSymbolicLink: attrs.isSymbolicLink(),
|
||||
}),
|
||||
unlinkAsync: async () => { unlinkCalls += 1; },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => api.deleteSftp(null, {
|
||||
sftpId: "sftp-1",
|
||||
path: "/usr/local/bin/tool",
|
||||
expectedType: "symlink",
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, "ESTALE");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(unlinkCalls, 0);
|
||||
});
|
||||
|
||||
test("SCP expected symlink delete stays non-recursive after the type check", async () => {
|
||||
let unlinkCalls = 0;
|
||||
let removeCalls = 0;
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { isDirectory: false, isSymbolicLink: true };
|
||||
},
|
||||
async unlink(remotePath, options) {
|
||||
unlinkCalls += 1;
|
||||
assert.equal(remotePath, "/usr/local/bin/tool");
|
||||
assert.equal(options.encoding, "utf-8");
|
||||
},
|
||||
async remove() {
|
||||
removeCalls += 1;
|
||||
},
|
||||
};
|
||||
const client = {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
};
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["scp-1", client]]),
|
||||
throwIfAborted() {},
|
||||
resolveEncodingForRequest: () => "utf-8",
|
||||
});
|
||||
|
||||
await api.deleteSftp(null, {
|
||||
sftpId: "scp-1",
|
||||
path: "/usr/local/bin/tool",
|
||||
expectedType: "symlink",
|
||||
});
|
||||
|
||||
assert.equal(unlinkCalls, 1);
|
||||
assert.equal(removeCalls, 0);
|
||||
});
|
||||
|
||||
test("expected symlink delete refuses a channel without native LSTAT", async () => {
|
||||
const channel = { stat() {} };
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
throwIfAborted() {},
|
||||
requireSftpChannel: async () => channel,
|
||||
resolveEncodingForRequest: () => "utf-8",
|
||||
encodePath: (remotePath) => remotePath,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => api.deleteSftp(null, {
|
||||
sftpId: "sftp-1",
|
||||
path: "/usr/local/bin/tool",
|
||||
expectedType: "symlink",
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, "ENOTSUP");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("non-UTF-8 expected symlink delete refuses a replacement file", async () => {
|
||||
const channel = { lstat() {} };
|
||||
let removeCalls = 0;
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
throwIfAborted() {},
|
||||
requireSftpChannel: async () => channel,
|
||||
resolveEncodingForRequest: () => "gb18030",
|
||||
normalizeRemotePathString: async (_client, remotePath) => remotePath,
|
||||
encodePath: (remotePath) => Buffer.from(remotePath),
|
||||
lstatAsync: async () => ({
|
||||
size: 4,
|
||||
mode: 0o100644,
|
||||
mtime: 20,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
}),
|
||||
statResultFromAttrs: (attrs) => ({
|
||||
size: attrs.size,
|
||||
modifyTime: attrs.mtime * 1000,
|
||||
mode: attrs.mode,
|
||||
isDirectory: attrs.isDirectory(),
|
||||
isSymbolicLink: attrs.isSymbolicLink(),
|
||||
}),
|
||||
removeRemotePathInternal: async () => { removeCalls += 1; },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => api.deleteSftp(null, {
|
||||
sftpId: "sftp-1",
|
||||
path: "/remote/tool",
|
||||
encoding: "gb18030",
|
||||
expectedType: "symlink",
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, "ESTALE");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(removeCalls, 0);
|
||||
});
|
||||
|
||||
test("non-UTF-8 expected symlink delete stays non-recursive after the type check", async () => {
|
||||
const channel = { lstat() {} };
|
||||
let unlinkCalls = 0;
|
||||
let removeCalls = 0;
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
throwIfAborted() {},
|
||||
requireSftpChannel: async () => channel,
|
||||
resolveEncodingForRequest: () => "gb18030",
|
||||
normalizeRemotePathString: async (_client, remotePath) => remotePath,
|
||||
encodePath: (remotePath) => Buffer.from(remotePath),
|
||||
lstatAsync: async () => ({
|
||||
size: 4,
|
||||
mode: 0o120777,
|
||||
mtime: 20,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => true,
|
||||
}),
|
||||
statResultFromAttrs: (attrs) => ({
|
||||
size: attrs.size,
|
||||
modifyTime: attrs.mtime * 1000,
|
||||
mode: attrs.mode,
|
||||
isDirectory: attrs.isDirectory(),
|
||||
isSymbolicLink: attrs.isSymbolicLink(),
|
||||
}),
|
||||
unlinkAsync: async (_sftp, encodedPath) => {
|
||||
unlinkCalls += 1;
|
||||
assert.equal(encodedPath.toString(), "/remote/tool");
|
||||
},
|
||||
removeRemotePathInternal: async () => { removeCalls += 1; },
|
||||
});
|
||||
|
||||
await api.deleteSftp(null, {
|
||||
sftpId: "sftp-1",
|
||||
path: "/remote/tool",
|
||||
encoding: "gb18030",
|
||||
expectedType: "symlink",
|
||||
});
|
||||
|
||||
assert.equal(unlinkCalls, 1);
|
||||
assert.equal(removeCalls, 0);
|
||||
});
|
||||
|
||||
test("listSftp includes owner from longname and falls back to uid", async () => {
|
||||
const channel = {
|
||||
readdir(_path, callback) {
|
||||
callback(null, [
|
||||
{
|
||||
filename: "root.txt",
|
||||
longname: "-rw-r--r-- 1 root root 12 Jan 1 00:00 root.txt",
|
||||
attrs: {
|
||||
size: 12,
|
||||
mtime: 1700000000,
|
||||
uid: 0,
|
||||
mode: 0o100644,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: "uid-only.bin",
|
||||
longname: "",
|
||||
attrs: {
|
||||
size: 1,
|
||||
mtime: 1700000000,
|
||||
uid: 1000,
|
||||
mode: 0o100644,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
const api = createFileOpsApi({
|
||||
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
|
||||
path: require("node:path"),
|
||||
normalizeEncoding: (value) => value || "utf-8",
|
||||
resolveEncodingForRequest: () => "utf-8",
|
||||
encodePath: (remotePath) => remotePath,
|
||||
requireSftpChannel: async () => channel,
|
||||
detectEncodingFromList: () => "utf-8",
|
||||
updateResolvedEncoding: (_id, _req, detected) => detected,
|
||||
decodeName: (raw) => (raw ? Buffer.from(raw).toString("utf8") : ""),
|
||||
isAsciiString: () => true,
|
||||
sftpEncodingState: new Map(),
|
||||
});
|
||||
|
||||
const entries = await api.listSftp(null, { sftpId: "sftp-1", path: "/home" });
|
||||
assert.equal(entries[0].name, "root.txt");
|
||||
assert.equal(entries[0].owner, "root");
|
||||
assert.equal(entries[1].name, "uid-only.bin");
|
||||
assert.equal(entries[1].owner, "1000");
|
||||
});
|
||||
Reference in New Issue
Block a user