[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:
988
electron/bridges/sftpBridge/scpAiTransferAbort.test.cjs
Normal file
988
electron/bridges/sftpBridge/scpAiTransferAbort.test.cjs
Normal file
@@ -0,0 +1,988 @@
|
||||
/**
|
||||
* Drive the shipped downloadSftpToLocal / uploadLocalToSftp SCP branches with
|
||||
* AbortSignal — the AI/MCP transfer path must cancel mid-flight, not only
|
||||
* throwIfAborted before/after.
|
||||
*/
|
||||
"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 sftpBridge = require("../sftpBridge.cjs");
|
||||
const { createScpBackend } = require("./scpBackend.cjs");
|
||||
|
||||
function createMockStream() {
|
||||
const ee = new EventEmitter();
|
||||
ee.writable = true;
|
||||
ee.readable = true;
|
||||
ee.stderr = new EventEmitter();
|
||||
ee.write = (buf, cb) => {
|
||||
if (typeof cb === "function") cb();
|
||||
return true;
|
||||
};
|
||||
ee.end = (cb) => { if (typeof cb === "function") cb(); };
|
||||
ee.close = () => {
|
||||
ee.closed = true;
|
||||
ee.destroyed = true;
|
||||
ee.emit("close");
|
||||
};
|
||||
ee.destroy = () => ee.close();
|
||||
return ee;
|
||||
}
|
||||
|
||||
describe("AI/MCP SCP transfer abort on shipped download/upload entry points", () => {
|
||||
let tmpDir;
|
||||
let sftpClients;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-scp-ai-abort-"));
|
||||
sftpClients = new Map();
|
||||
sftpBridge.init({
|
||||
electronModule: {},
|
||||
sessions: new Map(),
|
||||
sftpClients,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Aborted SCP transfers can still open the fixture briefly after the test
|
||||
// assertion settles. Drain that work before removing the temp directory so
|
||||
// Node does not promote a late ENOENT into a file-level failure.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
function registerScpClient(id, { hangOnStream = true } = {}) {
|
||||
const backend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
||||
execStream: async () => {
|
||||
const stream = createMockStream();
|
||||
if (!hangOnStream) {
|
||||
// ready ACK immediately for success paths (not used in abort tests)
|
||||
setImmediate(() => stream.emit("data", Buffer.from([0])));
|
||||
}
|
||||
// hang: never ACK so waitForAck blocks until cancel
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
backend.stat = async () => ({ type: "file", isDirectory: false, size: 256 });
|
||||
const client = {
|
||||
client: { exec: () => {} },
|
||||
sftp: null,
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
async end() {},
|
||||
};
|
||||
sftpClients.set(id, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("downloadSftpToLocal rejects when AbortSignal fires mid-SCP download", async () => {
|
||||
registerScpClient("scp-dl");
|
||||
const controller = new AbortController();
|
||||
const localPath = path.join(tmpDir, "out.bin");
|
||||
const original = Buffer.from("existing-local-content");
|
||||
fs.writeFileSync(localPath, original);
|
||||
const promise = sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-dl",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
controller.abort();
|
||||
await assert.rejects(() => promise, /cancel|abort/i);
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal settles when abort closes SCP before parser listeners attach", async () => {
|
||||
const controller = new AbortController();
|
||||
const backend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
||||
execStream: async (_command, options = {}) => {
|
||||
const stream = createMockStream();
|
||||
options.signal?.addEventListener?.("abort", () => stream.close(), { once: true });
|
||||
// Reproduce the transition race deterministically: the unified abort
|
||||
// listener marks the transfer cancelled and this stream closes before
|
||||
// downloadToWritable can attach its parser close/error listeners.
|
||||
controller.abort();
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
backend.stat = async () => ({ type: "file", isDirectory: false, size: 256 });
|
||||
sftpClients.set("scp-transition-abort", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
|
||||
const localPath = path.join(tmpDir, "transition-abort.bin");
|
||||
const original = Buffer.from("existing-local-content");
|
||||
fs.writeFileSync(localPath, original);
|
||||
const download = sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-transition-abort",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => Promise.race([
|
||||
download,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
|
||||
]),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal rejects when SCP closes before parser listeners attach", async () => {
|
||||
const backend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
||||
execStream: async () => {
|
||||
const stream = createMockStream();
|
||||
stream.close();
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
backend.stat = async () => ({ type: "file", isDirectory: false, size: 256 });
|
||||
sftpClients.set("scp-transition-close", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
|
||||
const localPath = path.join(tmpDir, "transition-close.bin");
|
||||
const original = Buffer.from("existing-local-content");
|
||||
fs.writeFileSync(localPath, original);
|
||||
const download = sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-transition-close",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => Promise.race([
|
||||
download,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("close timed out")), 500)),
|
||||
]),
|
||||
/closed|protocol|channel/i,
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
});
|
||||
|
||||
it("readSftpBinary reports AbortSignal-only SCP cancellation as cancelled", async () => {
|
||||
const controller = new AbortController();
|
||||
const backend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
||||
execStream: async (_command, options = {}) => {
|
||||
const stream = createMockStream();
|
||||
options.signal?.addEventListener?.("abort", () => stream.close(), { once: true });
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
sftpClients.set("scp-read-signal", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
|
||||
const read = sftpBridge.readSftpBinary(null, {
|
||||
sftpId: "scp-read-signal",
|
||||
path: "/remote/file.bin",
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
setImmediate(() => controller.abort());
|
||||
|
||||
await assert.rejects(
|
||||
() => Promise.race([
|
||||
read,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel timed out")), 500)),
|
||||
]),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal preserves the destination when cancellation arrives after SCP download", async () => {
|
||||
const controller = new AbortController();
|
||||
const downloaded = Buffer.from("new-downloaded-content");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
controller.abort();
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-late-abort", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const localPath = path.join(tmpDir, "late-abort.bin");
|
||||
const original = Buffer.from("existing-local-content");
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-late-abort",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal uses the SCP header size when downloading through a symlink", async () => {
|
||||
const downloaded = Buffer.from("target-content-is-longer-than-link");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "symlink", isSymbolicLink: true, size: 4 };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-symlink-download", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const localPath = path.join(tmpDir, "symlink-download.bin");
|
||||
|
||||
const result = await sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-symlink-download",
|
||||
remotePath: "/remote/link",
|
||||
localPath,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(fs.readFileSync(localPath), downloaded);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal preserves a local destination symlink", {
|
||||
skip: process.platform === "win32",
|
||||
}, async () => {
|
||||
const downloaded = Buffer.from("replacement-through-local-link");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-local-symlink", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const referentPath = path.join(tmpDir, "symlink-target.bin");
|
||||
const localPath = path.join(tmpDir, "download-link.bin");
|
||||
await fs.promises.writeFile(referentPath, Buffer.from("old-target-content"));
|
||||
await fs.promises.symlink(path.basename(referentPath), localPath);
|
||||
|
||||
const result = await sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-local-symlink",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal((await fs.promises.lstat(localPath)).isSymbolicLink(), true);
|
||||
assert.equal(await fs.promises.readlink(localPath), path.basename(referentPath));
|
||||
assert.deepEqual(await fs.promises.readFile(referentPath), downloaded);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal preserves a multi-level broken local symlink chain", {
|
||||
skip: process.platform === "win32",
|
||||
}, async () => {
|
||||
const downloaded = Buffer.from("replacement-through-broken-link-chain");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-broken-link-chain", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const finalPath = path.join(tmpDir, "missing-final.bin");
|
||||
const intermediatePath = path.join(tmpDir, "intermediate-link.bin");
|
||||
const localPath = path.join(tmpDir, "download-link-chain.bin");
|
||||
await fs.promises.symlink(path.basename(finalPath), intermediatePath);
|
||||
await fs.promises.symlink(path.basename(intermediatePath), localPath);
|
||||
|
||||
const result = await sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-broken-link-chain",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal((await fs.promises.lstat(localPath)).isSymbolicLink(), true);
|
||||
assert.equal((await fs.promises.lstat(intermediatePath)).isSymbolicLink(), true);
|
||||
assert.deepEqual(await fs.promises.readFile(finalPath), downloaded);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal refuses a local symlink that points to a directory", {
|
||||
skip: process.platform === "win32",
|
||||
}, async () => {
|
||||
const downloaded = Buffer.from("must-not-replace-a-directory");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-directory-link", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const directoryPath = path.join(tmpDir, "preserved-directory");
|
||||
const preservedPath = path.join(directoryPath, "keep.txt");
|
||||
const localPath = path.join(tmpDir, "directory-link");
|
||||
await fs.promises.mkdir(directoryPath);
|
||||
await fs.promises.writeFile(preservedPath, Buffer.from("keep-me"));
|
||||
await fs.promises.symlink(path.basename(directoryPath), localPath);
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-directory-link",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
}),
|
||||
/not a regular file/i,
|
||||
);
|
||||
assert.equal((await fs.promises.lstat(localPath)).isSymbolicLink(), true);
|
||||
assert.equal((await fs.promises.stat(directoryPath)).isDirectory(), true);
|
||||
assert.deepEqual(await fs.promises.readFile(preservedPath), Buffer.from("keep-me"));
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal refuses an existing local directory", async () => {
|
||||
const downloaded = Buffer.from("must-not-replace-a-directory");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-direct-directory", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const directoryPath = path.join(tmpDir, "direct-preserved-directory");
|
||||
const preservedPath = path.join(directoryPath, "keep.txt");
|
||||
await fs.promises.mkdir(directoryPath);
|
||||
await fs.promises.writeFile(preservedPath, Buffer.from("keep-me"));
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-direct-directory",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath: directoryPath,
|
||||
}),
|
||||
/not a regular file/i,
|
||||
);
|
||||
assert.equal((await fs.promises.stat(directoryPath)).isDirectory(), true);
|
||||
assert.deepEqual(await fs.promises.readFile(preservedPath), Buffer.from("keep-me"));
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal refuses a local symbolic-link loop", {
|
||||
skip: process.platform === "win32",
|
||||
}, async () => {
|
||||
const downloaded = Buffer.from("must-not-enter-a-link-loop");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-link-loop", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const firstLink = path.join(tmpDir, "first-loop-link");
|
||||
const secondLink = path.join(tmpDir, "second-loop-link");
|
||||
await fs.promises.symlink(path.basename(secondLink), firstLink);
|
||||
await fs.promises.symlink(path.basename(firstLink), secondLink);
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-link-loop",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath: firstLink,
|
||||
}),
|
||||
/symbolic-link loop/i,
|
||||
);
|
||||
assert.equal((await fs.promises.lstat(firstLink)).isSymbolicLink(), true);
|
||||
assert.equal((await fs.promises.lstat(secondLink)).isSymbolicLink(), true);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal allows a valid path to revisit one symlink", {
|
||||
skip: process.platform === "win32",
|
||||
}, async () => {
|
||||
const downloaded = Buffer.from("valid-repeated-link-path");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-repeated-link", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const nestedDir = path.join(tmpDir, "repeated-link-dir");
|
||||
const upLink = path.join(nestedDir, "up");
|
||||
await fs.promises.mkdir(nestedDir);
|
||||
await fs.promises.symlink("..", upLink);
|
||||
const localPath = path.join(upLink, path.basename(nestedDir), "up", "repeated-final.bin");
|
||||
|
||||
const result = await sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-repeated-link",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(await fs.promises.readFile(path.join(tmpDir, "repeated-final.bin")), downloaded);
|
||||
assert.equal((await fs.promises.lstat(upLink)).isSymbolicLink(), true);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal preserves the existing local file mode", {
|
||||
skip: process.platform === "win32",
|
||||
}, async () => {
|
||||
const downloaded = Buffer.from("replacement-with-private-mode");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-local-mode", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const localPath = path.join(tmpDir, "private-download.bin");
|
||||
await fs.promises.writeFile(localPath, Buffer.from("private-old-content"), { mode: 0o600 });
|
||||
await fs.promises.chmod(localPath, 0o600);
|
||||
|
||||
const result = await sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-local-mode",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(await fs.promises.readFile(localPath), downloaded);
|
||||
assert.equal((await fs.promises.stat(localPath)).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal stops when a parent symlink changes before promotion", {
|
||||
skip: process.platform === "win32",
|
||||
}, async (t) => {
|
||||
const downloaded = Buffer.from("must-not-follow-the-new-parent");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-parent-link-change", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const firstDir = path.join(tmpDir, "first-parent");
|
||||
const secondDir = path.join(tmpDir, "second-parent");
|
||||
const parentLink = path.join(tmpDir, "parent-link");
|
||||
const localPath = path.join(parentLink, "target.bin");
|
||||
const firstOriginal = Buffer.from("first-original");
|
||||
const secondOriginal = Buffer.from("second-original");
|
||||
await fs.promises.mkdir(firstDir);
|
||||
await fs.promises.mkdir(secondDir);
|
||||
await fs.promises.writeFile(path.join(firstDir, "target.bin"), firstOriginal);
|
||||
await fs.promises.writeFile(path.join(secondDir, "target.bin"), secondOriginal);
|
||||
await fs.promises.symlink(path.basename(firstDir), parentLink);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 1) {
|
||||
await fs.promises.unlink(parentLink);
|
||||
await fs.promises.symlink(path.basename(secondDir), parentLink);
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-parent-link-change",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
}),
|
||||
/target changed before replacement/i,
|
||||
);
|
||||
assert.equal(await fs.promises.readlink(parentLink), path.basename(secondDir));
|
||||
assert.deepEqual(await fs.promises.readFile(path.join(firstDir, "target.bin")), firstOriginal);
|
||||
assert.deepEqual(await fs.promises.readFile(path.join(secondDir, "target.bin")), secondOriginal);
|
||||
const leftovers = (await fs.promises.readdir(secondDir))
|
||||
.filter((name) => name.includes(".netcatty-") || name.includes(".backup"));
|
||||
assert.deepEqual(leftovers, []);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal leaves the original untouched when mode setup fails", {
|
||||
skip: process.platform === "win32",
|
||||
}, async (t) => {
|
||||
const downloaded = Buffer.from("replacement-that-must-not-publish");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-mode-setup-failure", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const localPath = path.join(tmpDir, "mode-setup-failure.bin");
|
||||
const original = Buffer.from("private-original");
|
||||
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
|
||||
await fs.promises.chmod(localPath, 0o600);
|
||||
|
||||
const originalChmod = fs.promises.chmod;
|
||||
fs.promises.chmod = async (filePath, mode) => {
|
||||
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
|
||||
const error = new Error("injected chmod failure");
|
||||
error.code = "EPERM";
|
||||
throw error;
|
||||
}
|
||||
return originalChmod(filePath, mode);
|
||||
};
|
||||
t.after(() => { fs.promises.chmod = originalChmod; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-mode-setup-failure",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
}),
|
||||
/chmod failure/i,
|
||||
);
|
||||
assert.deepEqual(await fs.promises.readFile(localPath), original);
|
||||
assert.equal((await fs.promises.stat(localPath)).mode & 0o777, 0o600);
|
||||
const leftovers = (await fs.promises.readdir(tmpDir))
|
||||
.filter((name) => name.includes("mode-setup-failure.bin.netcatty-"));
|
||||
assert.deepEqual(leftovers, []);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal rechecks the target after mode setup", {
|
||||
skip: process.platform === "win32",
|
||||
}, async (t) => {
|
||||
const downloaded = Buffer.from("must-not-replace-late-directory");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-late-directory", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const localPath = path.join(tmpDir, "late-directory-target");
|
||||
const savedOriginalPath = path.join(tmpDir, "saved-late-original.bin");
|
||||
const original = Buffer.from("original-before-late-change");
|
||||
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
|
||||
|
||||
const originalChmod = fs.promises.chmod;
|
||||
let releaseChmod;
|
||||
let markChmodStarted;
|
||||
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
|
||||
fs.promises.chmod = async (filePath, mode) => {
|
||||
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
|
||||
markChmodStarted();
|
||||
await new Promise((resolve) => { releaseChmod = resolve; });
|
||||
}
|
||||
return originalChmod(filePath, mode);
|
||||
};
|
||||
t.after(() => { fs.promises.chmod = originalChmod; });
|
||||
|
||||
const running = sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-late-directory",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
});
|
||||
await chmodStarted;
|
||||
await fs.promises.rename(localPath, savedOriginalPath);
|
||||
await fs.promises.mkdir(localPath);
|
||||
releaseChmod();
|
||||
|
||||
await assert.rejects(() => running, /not a regular file/i);
|
||||
assert.equal((await fs.promises.stat(localPath)).isDirectory(), true);
|
||||
assert.deepEqual(await fs.promises.readFile(savedOriginalPath), original);
|
||||
const leftovers = (await fs.promises.readdir(tmpDir))
|
||||
.filter((name) => name.includes("late-directory-target.netcatty-"));
|
||||
assert.deepEqual(leftovers, []);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal does not overwrite a same-mode file that appears during mode setup", {
|
||||
skip: process.platform === "win32",
|
||||
}, async (t) => {
|
||||
const downloaded = Buffer.from("must-not-replace-a-new-owner-file");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-same-mode-replacement", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const localPath = path.join(tmpDir, "same-mode-replacement.bin");
|
||||
const savedOriginalPath = path.join(tmpDir, "saved-same-mode-original.bin");
|
||||
const original = Buffer.from("original-before-replacement");
|
||||
const newOwnerContent = Buffer.from("new-owner-content");
|
||||
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
|
||||
await fs.promises.chmod(localPath, 0o600);
|
||||
|
||||
const originalChmod = fs.promises.chmod;
|
||||
let releaseChmod;
|
||||
let markChmodStarted;
|
||||
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
|
||||
fs.promises.chmod = async (filePath, mode) => {
|
||||
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
|
||||
markChmodStarted();
|
||||
await new Promise((resolve) => { releaseChmod = resolve; });
|
||||
}
|
||||
return originalChmod(filePath, mode);
|
||||
};
|
||||
t.after(() => { fs.promises.chmod = originalChmod; });
|
||||
|
||||
const running = sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-same-mode-replacement",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
});
|
||||
await chmodStarted;
|
||||
await fs.promises.rename(localPath, savedOriginalPath);
|
||||
await fs.promises.writeFile(localPath, newOwnerContent, { mode: 0o600 });
|
||||
await fs.promises.chmod(localPath, 0o600);
|
||||
releaseChmod();
|
||||
|
||||
await assert.rejects(() => running, /target changed before replacement/i);
|
||||
assert.deepEqual(await fs.promises.readFile(localPath), newOwnerContent);
|
||||
assert.deepEqual(await fs.promises.readFile(savedOriginalPath), original);
|
||||
assert.equal((await fs.promises.stat(localPath)).mode & 0o777, 0o600);
|
||||
const leftovers = (await fs.promises.readdir(tmpDir))
|
||||
.filter((name) => name.includes("same-mode-replacement.bin.netcatty-"));
|
||||
assert.deepEqual(leftovers, []);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal restores the destination when cancellation arrives during promotion", async (t) => {
|
||||
const controller = new AbortController();
|
||||
const downloaded = Buffer.from("new-downloaded-content");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-promotion-abort", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const localPath = path.join(tmpDir, "promotion-abort.bin");
|
||||
const original = Buffer.from("existing-local-content");
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 2) controller.abort();
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-promotion-abort",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal keeps the committed download when cancellation arrives after publication", async (t) => {
|
||||
const controller = new AbortController();
|
||||
const downloaded = Buffer.from("new-downloaded-content");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-final-rename-abort", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const localPath = path.join(tmpDir, "final-rename-abort.bin");
|
||||
const original = Buffer.from("existing-local-content");
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalLink = fs.promises.link;
|
||||
let published = false;
|
||||
fs.promises.link = async (...args) => {
|
||||
await originalLink(...args);
|
||||
if (path.basename(args[1]) === path.basename(localPath) && String(args[0]).endsWith(".ready")) {
|
||||
published = true;
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.link = originalLink; });
|
||||
|
||||
await assert.doesNotReject(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-final-rename-abort",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
);
|
||||
assert.equal(published, true);
|
||||
assert.deepEqual(fs.readFileSync(localPath), downloaded);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal reports and preserves the backup when cancellation rollback fails", async (t) => {
|
||||
const controller = new AbortController();
|
||||
const downloaded = Buffer.from("new-downloaded-content");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-rollback-failure", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const localPath = path.join(tmpDir, "rollback-failure.bin");
|
||||
const original = Buffer.from("existing-local-content");
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
const originalLink = fs.promises.link;
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
if (path.basename(args[0]) === path.basename(localPath) && String(args[1]).endsWith(".backup")) {
|
||||
backupPath = args[1];
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
fs.promises.link = async (...args) => {
|
||||
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
|
||||
return originalLink(...args);
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-rollback-failure",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Recovery files preserved/);
|
||||
assert.match(error.message, /Backup:/);
|
||||
assert.doesNotMatch(error.message, /^Transfer cancelled$/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.ok(backupPath);
|
||||
assert.deepEqual(fs.readFileSync(backupPath), original);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal reports both recovery files when pre-publish restoration fails", async (t) => {
|
||||
const controller = new AbortController();
|
||||
const downloaded = Buffer.from("new-downloaded-content");
|
||||
const backend = {
|
||||
async stat() {
|
||||
return { type: "file", isDirectory: false, size: downloaded.length };
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded);
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-pre-publish-rollback-failure", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const localPath = path.join(tmpDir, "pre-publish-rollback-failure.bin");
|
||||
fs.writeFileSync(localPath, Buffer.from("existing-local-content"));
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
let readyPath = null;
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
renameCalls += 1;
|
||||
await originalRename(...args);
|
||||
if (renameCalls === 1) readyPath = args[1];
|
||||
if (renameCalls === 2) {
|
||||
backupPath = args[1];
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
const originalLink = fs.promises.link;
|
||||
fs.promises.link = async (...args) => {
|
||||
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
|
||||
return originalLink(...args);
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-pre-publish-rollback-failure",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Recovery files preserved/);
|
||||
assert.match(error.message, new RegExp(readyPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(error.message, new RegExp(backupPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(fs.existsSync(readyPath), true);
|
||||
assert.equal(fs.existsSync(backupPath), true);
|
||||
});
|
||||
|
||||
it("uploadLocalToSftp rejects when AbortSignal fires mid-SCP upload", async () => {
|
||||
registerScpClient("scp-up");
|
||||
const localPath = path.join(tmpDir, "in.bin");
|
||||
fs.writeFileSync(localPath, Buffer.alloc(256, 9));
|
||||
const controller = new AbortController();
|
||||
const promise = sftpBridge.uploadLocalToSftp(null, {
|
||||
sftpId: "scp-up",
|
||||
localPath,
|
||||
remotePath: "/remote/in.bin",
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
controller.abort();
|
||||
await assert.rejects(() => promise, /cancel|abort/i);
|
||||
});
|
||||
|
||||
it("uploadLocalToSftp aborts while SCP target inspection is still pending", async () => {
|
||||
let uploadCalls = 0;
|
||||
const backend = {
|
||||
async stat(_remotePath, options = {}) {
|
||||
await new Promise((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(new Error("Transfer cancelled"));
|
||||
return;
|
||||
}
|
||||
options.signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("Transfer cancelled")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
async uploadFile() {
|
||||
uploadCalls += 1;
|
||||
},
|
||||
};
|
||||
sftpClients.set("scp-setup-abort", {
|
||||
__netcattyFileProtocol: "scp",
|
||||
__netcattyScpBackend: backend,
|
||||
});
|
||||
const localPath = path.join(tmpDir, "setup.bin");
|
||||
fs.writeFileSync(localPath, Buffer.alloc(16, 1));
|
||||
const controller = new AbortController();
|
||||
const startedAt = Date.now();
|
||||
const promise = sftpBridge.uploadLocalToSftp(null, {
|
||||
sftpId: "scp-setup-abort",
|
||||
localPath,
|
||||
remotePath: "/remote/setup.bin",
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
controller.abort();
|
||||
await assert.rejects(() => promise, /cancel|abort/i);
|
||||
assert.equal(uploadCalls, 0);
|
||||
assert.ok(Date.now() - startedAt < 1000);
|
||||
// Keep the fixture alive until any in-flight open from the aborted setup
|
||||
// path has a chance to settle against the still-present file.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
it("legacy entry points delegate cancellation to the unified transfer engine", () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, "../sftpBridge.cjs"), "utf8");
|
||||
assert.doesNotMatch(src, /cancelledFlag/);
|
||||
assert.match(
|
||||
src,
|
||||
/async function downloadSftpToLocal\(_event, payload\) \{\s*return runUnifiedSftpTransfer\(payload, "download"\);\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
src,
|
||||
/async function uploadLocalToSftp\(_event, payload\) \{\s*return runUnifiedSftpTransfer\(payload, "upload"\);\s*\}/,
|
||||
);
|
||||
const unifiedIdx = src.indexOf("async function runUnifiedSftpTransfer");
|
||||
const unifiedBlock = src.slice(unifiedIdx, src.indexOf("async function downloadSftpToLocal", unifiedIdx));
|
||||
assert.match(unifiedBlock, /transferBridge\.startTransfer/);
|
||||
assert.match(unifiedBlock, /transferBridge\.cancelTransfer/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user