"use strict"; // End-to-end tests of privileged editing and SFTP transfers against a disposable Docker container // over real SSH (sshd, sftp-server, sudo, python3, ACL/xattr). Never point this at a real server. // Run explicitly: CHJ_FILES_INTEGRATION=1 CHJ_FILES_IMAGE=chj-files-it-debian node --test test/filesIntegration.test.js // Images: test/fixtures/filesIntegration (Dockerfile.debian with BASE=debian/ubuntu, Dockerfile.rocky). const test = 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 { execFileSync, spawnSync } = require("node:child_process"); const { SessionManager } = require("../src/main/sessions/sessionManager"); const { PrivilegedExecutor } = require("../src/main/sessions/privilegedExec"); const { RemoteFileService } = require("../src/main/files/remoteFileService"); const { TransferManager } = require("../src/main/files/transferManager"); const enabled = process.env.CHJ_FILES_INTEGRATION === "1"; const image = process.env.CHJ_FILES_IMAGE || "chj-files-it-debian"; const skip = enabled ? false : "Set CHJ_FILES_INTEGRATION=1 to run against a disposable Docker container."; const container = `chj-files-it-${process.pid}`; const ADMIN = { sudoPassword: "AdminPass-1" }; function docker(args) { return execFileSync("docker", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }); } function sh(command, { allowFailure = false } = {}) { const result = spawnSync("docker", ["exec", container, "sh", "-c", command], { encoding: "utf8" }); if (!allowFailure && result.status !== 0) throw new Error(`container command failed (${result.status}): ${command}\n${result.stderr}`); return result.stdout.trim(); } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function until(predicate, timeout = 60000, label = "condition") { const started = Date.now(); while (!(await predicate())) { if (Date.now() - started > timeout) throw new Error(`Timed out waiting for ${label}`); await sleep(50); } } class Profiles { constructor() { this.profiles = new Map(); this.hostKeys = new Map(); this.passwords = new Map(); } add(profile, password) { this.profiles.set(profile.id, profile); if (password) this.passwords.set(profile.id, password); } get(id) { const profile = this.profiles.get(id); if (!profile) throw new Error("Profile not found"); return profile; } getHostKey(host, port) { return this.hostKeys.get(`${host}:${port}`) || null; } trustHostKey(host, port, fingerprint) { this.hostKeys.set(`${host}:${port}`, fingerprint); return { trusted: true }; } getStoredPassword(id) { return this.passwords.get(id) || ""; } markUsed() {} } function freePort() { return new Promise((resolve) => { const server = require("node:net").createServer(); server.listen(0, "127.0.0.1", () => { const { port } = server.address(); server.close(() => resolve(port)); }); }); } test(`File Manager privileged editing and transfers on ${image}`, { skip, timeout: 1800000 }, async (t) => { // A fixed host port keeps the server identity stable across the container (SSH server) restart. const hostPort = await freePort(); docker(["run", "-d", "--rm", "--name", container, "--tmpfs", "/srv/tiny:size=64k,mode=0755", "-p", `127.0.0.1:${hostPort}:22`, image]); t.after(() => { try { docker(["rm", "-f", container]); } catch {} }); let port = Number(docker(["port", container, "22/tcp"]).trim().split(":").pop()); await until(() => spawnSync("docker", ["exec", container, "test", "-s", "/run/sshd.pid"]).status === 0 || spawnSync("docker", ["exec", container, "pgrep", "sshd"]).status === 0, 20000, "sshd"); const work = fs.mkdtempSync(path.join(os.tmpdir(), "chj-files-it-")); t.after(() => fs.rmSync(work, { recursive: true, force: true })); for (const name of ["root", "nopass"]) execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", name, "-f", path.join(work, name)]); const pub = (name) => fs.readFileSync(path.join(work, `${name}.pub`), "utf8").trim(); sh(`mkdir -p /root/.ssh && printf '%s\\n' '${pub("root")}' > /root/.ssh/authorized_keys && chmod 700 /root/.ssh && chmod 600 /root/.ssh/authorized_keys`); sh(`install -d -m 700 -o nopass -g nopass /home/nopass/.ssh && printf '%s\\n' '${pub("nopass")}' > /home/nopass/.ssh/authorized_keys && chown nopass:nopass /home/nopass/.ssh/authorized_keys && chmod 600 /home/nopass/.ssh/authorized_keys`); sh("grep -q '^PermitRootLogin' /etc/ssh/sshd_config || printf 'PermitRootLogin prohibit-password\\n' >> /etc/ssh/sshd_config; kill -HUP $(cat /run/sshd.pid 2>/dev/null || pgrep -o sshd)"); await sleep(700); // Test fixtures in /etc: owners, modes, ACL, xattr, symlink layout like nginx, a hardlink, a root-only file. sh(`set -e; mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled printf 'worker_processes 1;\\n' > /etc/nginx/nginx.conf; chmod 0644 /etc/nginx/nginx.conf setfacl -m u:plain:r-- /etc/nginx/nginx.conf; setfattr -n user.chj -v kept /etc/nginx/nginx.conf printf 'server { listen 80; }\\n' > /etc/nginx/sites-available/example.conf ln -s ../sites-available/example.conf /etc/nginx/sites-enabled/example.conf printf 'secret=1\\n' > /etc/chj-secret.conf; chmod 0600 /etc/chj-secret.conf printf 'shared\\n' > /etc/chj-hard.conf; ln /etc/chj-hard.conf /etc/chj-hard-link.conf printf 'tiny\\n' > /srv/tiny/small.conf; chmod 0640 /srv/tiny/small.conf; chgrp adm /srv/tiny/small.conf 2>/dev/null || chgrp wheel /srv/tiny/small.conf`); const facts = (file) => sh(`stat -c '%u:%g:%a' ${file}; getfacl -cp ${file} 2>/dev/null | grep -v '^#' | sort | tr '\\n' ' '; getfattr -d --absolute-names -m '^user\\.' ${file} 2>/dev/null | grep -v '^#' | tr '\\n' ' '`); t.diagnostic(`image=${image} ${sh("sshd -V 2>&1 | head -n 1; python3 --version", { allowFailure: true })}`); const profiles = new Profiles(); const base = { host: "127.0.0.1", port, label: "it" }; profiles.add({ ...base, id: "admin", username: "admin", authMethod: "password" }, "AdminPass-1"); profiles.add({ ...base, id: "plain", username: "plain", authMethod: "password" }, "PlainPass-1"); profiles.add({ ...base, id: "nopass", username: "nopass", authMethod: "privateKey", privateKeyPath: path.join(work, "nopass") }); profiles.add({ ...base, id: "root", username: "root", authMethod: "privateKey", privateKeyPath: path.join(work, "root") }); const manager = new SessionManager({ profileService: profiles, latencyMonitor: { start: () => () => {} } }); const executor = new PrivilegedExecutor({ sessionManager: manager }); manager.privilegedExecutor = executor; let uploadSelection = []; let downloadTarget = work; const files = new RemoteFileService({ sessionManager: manager, privilegedExecutor: executor, selectUploadPath: async () => ({ canceled: false, paths: uploadSelection }), selectDownloadPath: async (name) => ({ canceled: false, path: path.join(downloadTarget, name) }), selectDownloadDirectory: async () => ({ canceled: false, path: downloadTarget }) }); const storage = path.join(work, "state"); let transfers = new TransferManager({ sessionManager: manager, remoteFileService: files, storageRoot: storage, retryDelaysMs: [500, 1000, 2000] }); t.after(() => { transfers.shutdown(); return manager.disconnectAll("test"); }); const connect = async (id) => { try { await manager.connect({ sessionId: id, profileId: id }); } catch (error) { if (error.code !== "HOST_KEY_UNKNOWN") throw error; manager.trustPending({ sessionId: id, profileId: id, fingerprint: error.fingerprint }); await manager.connect({ sessionId: id, profileId: id }); } }; for (const id of ["root", "admin", "nopass", "plain"]) await connect(id); const FINALISH = new Set(["completed", "failed", "cancelled", "interrupted", "paused"]); const jobsOf = () => transfers.list("fm").jobs; const lastJob = () => jobsOf().at(-1); await t.test("root reads and saves /etc directly, preserving owner, mode, ACL and xattr", async () => { const before = facts("/etc/nginx/nginx.conf"); const mtime = sh("stat -c %Y /etc/nginx/nginx.conf"); const opened = await files.readText("root", "/etc/nginx/nginx.conf"); assert.equal(opened.text, "worker_processes 1;\n"); assert.equal(opened.directSave, true); await sleep(1100); const saved = await files.writeText("root", "/etc/nginx/nginx.conf", "worker_processes 2;\n", { editId: opened.editId }); assert.equal(saved.status, "saved"); assert.equal(sh("cat /etc/nginx/nginx.conf"), "worker_processes 2;"); assert.equal(facts("/etc/nginx/nginx.conf"), before, "uid/gid/mode/ACL/xattr identical"); assert.notEqual(sh("stat -c %Y /etc/nginx/nginx.conf"), mtime, "mtime reflects the new edit"); assert.equal(sh("ls -a /etc/nginx | grep -c chj || true"), "0", "no temporary files left in /etc/nginx"); const privileges = await files.privileges("root"); assert.equal(privileges.mode, "root"); }); await t.test("root creates, renames and removes entries in /etc and reports real filesystem errors", async () => { await files.mkdir("root", "/etc/chj-dir"); await files.createFile("root", "/etc/chj-dir/new.conf"); await files.rename("root", "/etc/chj-dir/new.conf", "/etc/chj-dir/renamed.conf"); await assert.rejects(() => files.createFile("root", "/etc/chj-dir/renamed.conf"), { code: "FILE_EXISTS" }); await files.removeMany("root", ["/etc/chj-dir"], true); assert.equal(sh("test -e /etc/chj-dir && echo yes || echo no"), "no"); await assert.rejects(() => files.removeMany("root", ["/etc"], true), { code: "FILE_PROTECTED_PATH" }); }); await t.test("sudo user: unprivileged access fails clearly, sudo with password reads and saves", async () => { await assert.rejects(() => files.readText("admin", "/etc/chj-secret.conf"), { code: "FILE_PERMISSION_DENIED" }); await assert.rejects(() => files.readText("admin", "/etc/chj-secret.conf", { sudo: true }), { code: "SUDO_PASSWORD_REQUIRED" }); await assert.rejects(() => files.readText("admin", "/etc/chj-secret.conf", { sudo: true, sudoPassword: "wrong" }), { code: "SUDO_PASSWORD_INCORRECT" }); const secret = await files.readText("admin", "/etc/chj-secret.conf", { sudo: true, ...ADMIN }); assert.equal(secret.text, "secret=1\n"); const before = facts("/etc/nginx/nginx.conf"); const opened = await files.readText("admin", "/etc/nginx/nginx.conf"); assert.equal(opened.directSave, false, "UI is told that saving needs sudo"); const denied = await files.saveText("admin", "/etc/nginx/nginx.conf", "worker_processes 3;\n", { editId: opened.editId }); assert.equal(denied.ok, false); assert.equal(denied.error.code, "FILE_PERMISSION_DENIED"); const wrong = await files.saveText("admin", "/etc/nginx/nginx.conf", "worker_processes 3;\n", { editId: opened.editId, sudo: true, sudoPassword: "wrong" }); assert.equal(wrong.error.code, "SUDO_PASSWORD_INCORRECT"); assert.equal(wrong.error.status, "failed"); assert.equal(sh("cat /etc/nginx/nginx.conf"), "worker_processes 2;", "original untouched after refused sudo"); const saved = await files.saveText("admin", "/etc/nginx/nginx.conf", "worker_processes 3;\n", { editId: opened.editId, sudo: true, ...ADMIN, rememberSudo: true }); assert.equal(saved.ok, true, JSON.stringify(saved.error)); assert.equal(sh("cat /etc/nginx/nginx.conf"), "worker_processes 3;"); assert.equal(facts("/etc/nginx/nginx.conf"), before); const again = await files.readText("admin", "/etc/chj-secret.conf", { sudo: true }); assert.equal(again.text, "secret=1\n", "remembered sudo password reused from memory"); }); await t.test("NOPASSWD sudo works without a password; an account without sudo gets a clear error", async () => { const opened = await files.readText("nopass", "/etc/chj-secret.conf", { sudo: true }); const saved = await files.saveText("nopass", "/etc/chj-secret.conf", "secret=2\n", { editId: opened.editId, sudo: true }); assert.equal(saved.ok, true, JSON.stringify(saved.error)); assert.equal(sh("stat -c '%u:%g:%a' /etc/chj-secret.conf"), "0:0:600"); assert.equal((await files.privileges("nopass")).mode, "sudo-nopasswd"); await assert.rejects(() => files.readText("plain", "/etc/chj-secret.conf", { sudo: true, sudoPassword: "PlainPass-1" }), { code: "SUDO_NOT_PERMITTED" }); await assert.rejects(() => files.createFile("plain", "/etc/x.conf", { sudo: true, sudoPassword: "PlainPass-1" }), { code: "SUDO_NOT_PERMITTED" }); }); await t.test("editing through a symlink keeps the link and changes its target", async () => { const opened = await files.readText("admin", "/etc/nginx/sites-enabled/example.conf"); assert.equal(opened.link, true); assert.equal(opened.resolvedPath, "/etc/nginx/sites-available/example.conf"); const saved = await files.saveText("admin", "/etc/nginx/sites-enabled/example.conf", "server { listen 8080; }\n", { editId: opened.editId, sudo: true }); assert.equal(saved.ok, true, JSON.stringify(saved.error)); assert.equal(sh("readlink /etc/nginx/sites-enabled/example.conf"), "../sites-available/example.conf"); assert.equal(sh("cat /etc/nginx/sites-available/example.conf"), "server { listen 8080; }"); const listed = await files.list("admin", "/etc/nginx/sites-enabled"); assert.equal(listed[0].type, "symlink"); assert.equal(listed[0].targetType, "file"); }); await t.test("hardlinked files are not replaced automatically", async () => { await assert.rejects(() => files.readText("root", "/etc/chj-hard.conf"), { code: "FILE_HARDLINK_UNSUPPORTED" }); }); await t.test("a change by another process is detected and nothing is overwritten", async () => { const opened = await files.readText("root", "/etc/nginx/nginx.conf"); sh("printf 'external\\n' >> /etc/nginx/nginx.conf"); const result = await files.saveText("root", "/etc/nginx/nginx.conf", "mine\n", { editId: opened.editId }); assert.equal(result.error.code, "FILE_CONFLICT"); assert.match(sh("cat /etc/nginx/nginx.conf"), /external$/); }); await t.test("no space on the target filesystem leaves the original intact", async () => { const opened = await files.readText("root", "/srv/tiny/small.conf"); const before = facts("/srv/tiny/small.conf"); const result = await files.saveText("root", "/srv/tiny/small.conf", "x".repeat(200 * 1024), { editId: opened.editId }); assert.equal(result.ok, false); assert.equal(result.error.code, "FILE_NO_SPACE"); assert.equal(sh("cat /srv/tiny/small.conf"), "tiny"); assert.equal(facts("/srv/tiny/small.conf"), before); assert.equal(sh("ls -a /srv/tiny | grep -c chj || true"), "0"); }); await t.test("upload and download of a regular file with verification", async () => { const local = path.join(work, "regular.bin"); const data = crypto.randomBytes(3 * 1024 * 1024 + 11); fs.writeFileSync(local, data); uploadSelection = [local]; const plan = await transfers.planUpload("fm", "admin", "/home/admin"); assert.equal(plan.mode, "direct"); await transfers.start("fm", plan.planId); await until(() => lastJob()?.state === "completed", 60000, "upload"); assert.equal(sh("sha256sum /home/admin/regular.bin | cut -d' ' -f1"), crypto.createHash("sha256").update(data).digest("hex")); assert.equal(sh("stat -c %U /home/admin/regular.bin"), "admin"); const downloads = fs.mkdtempSync(path.join(work, "dl-")); downloadTarget = downloads; const download = await transfers.planDownload("fm", "admin", ["/home/admin/regular.bin"]); await transfers.start("fm", download.planId); await until(() => lastJob()?.state === "completed", 60000, "download"); assert.deepEqual(fs.readFileSync(path.join(downloads, "regular.bin")), data); assert.equal(lastJob().verification.matched, true); }); await t.test("upload into /etc publishes through sudo and keeps the existing file's metadata", async () => { const local = path.join(work, "nginx.conf"); fs.writeFileSync(local, "worker_processes auto;\n".repeat(1000)); sh("printf 'worker_processes 1;\\n' > /etc/nginx/nginx.conf"); const before = facts("/etc/nginx/nginx.conf"); uploadSelection = [local]; const plan = await transfers.planUpload("fm", "admin", "/etc/nginx"); assert.equal(plan.mode, "privileged"); assert.equal(plan.conflictCount, 1); executor.forgetPassword("admin"); await assert.rejects(() => transfers.start("fm", plan.planId, { conflict: "overwrite", sudoPassword: "wrong" }), { code: "SUDO_PASSWORD_INCORRECT" }); await transfers.start("fm", plan.planId, { conflict: "overwrite", ...ADMIN }); await until(() => ["completed", "failed", "interrupted"].includes(lastJob()?.state), 60000, "privileged upload"); assert.equal(lastJob().state, "completed", JSON.stringify(lastJob().error)); assert.equal(sh("sha256sum /etc/nginx/nginx.conf | cut -d' ' -f1"), crypto.createHash("sha256").update(fs.readFileSync(local)).digest("hex")); assert.equal(facts("/etc/nginx/nginx.conf"), before, "owner/mode/ACL/xattr preserved"); assert.equal(lastJob().finalize.metadataPreserved, true); assert.equal(sh("ls -A /home/admin/ch-j-sm/transfers | wc -l"), "0", "staging cleaned"); // A new file in a protected directory gets root ownership and the requested mode. const fresh = path.join(work, "fresh.conf"); fs.writeFileSync(fresh, "new\n"); uploadSelection = [fresh]; const second = await transfers.planUpload("fm", "admin", "/etc/nginx/sites-available"); await transfers.start("fm", second.planId, ADMIN); await until(() => lastJob()?.state === "completed", 60000, "new privileged upload"); assert.equal(sh("stat -c '%u:%g:%a' /etc/nginx/sites-available/fresh.conf"), "0:0:644"); }); await t.test("privileged download of a root-only file", async () => { const downloads = fs.mkdtempSync(path.join(work, "dl-")); downloadTarget = downloads; await assert.rejects(() => transfers.planDownload("fm", "plain", ["/etc/chj-secret.conf"]).then(async (plan) => { await transfers.start("fm", plan.planId); await until(() => FINALISH.has(lastJob().state), 30000, "plain download"); throw Object.assign(new Error(lastJob().error?.code), { code: lastJob().error?.code }); }), { code: "FILE_PERMISSION_DENIED" }); const plan = await transfers.planDownload("fm", "admin", ["/etc/chj-secret.conf"], { sudo: true }); await transfers.start("fm", plan.planId, { sudo: true, ...ADMIN }); await until(() => lastJob()?.state === "completed", 60000, "privileged download"); assert.equal(fs.readFileSync(path.join(downloads, "chj-secret.conf"), "utf8"), "secret=2\n"); assert.equal(fs.statSync(path.join(downloads, "chj-secret.conf")).mode & 0o777, 0o600); }); const big = path.join(work, "big.bin"); const fd = fs.openSync(big, "w"); const block = crypto.randomBytes(4 * 1024 * 1024); for (let index = 0; index < 64; index += 1) fs.writeSync(fd, block); fs.closeSync(fd); const bigHash = crypto.createHash("sha256").update(fs.readFileSync(big)).digest("hex"); // 1 GiB sparse file: long enough to interrupt a running transfer on a fast local link. const huge = path.join(work, "huge.bin"); fs.closeSync(fs.openSync(huge, "w")); fs.truncateSync(huge, 1024 * 1024 * 1024); const hugeHash = await new Promise((resolve) => { const digest = crypto.createHash("sha256"); fs.createReadStream(huge).on("data", (chunk) => digest.update(chunk)).on("end", () => resolve(digest.digest("hex"))); }); const remoteHash = (file) => sh(`sha256sum ${file} | cut -d' ' -f1`); await t.test("pause/resume continues at the confirmed offset over real SFTP", async () => { uploadSelection = [big]; const plan = await transfers.planUpload("fm", "admin", "/home/admin"); await transfers.start("fm", plan.planId, { conflict: "overwrite" }); await until(() => lastJob()?.transferred > 32 * 1024 * 1024, 120000, "progress"); transfers.pause("fm", [lastJob().id]); await until(() => lastJob().state === "paused", 30000, "paused"); const offset = lastJob().transferred; const partSize = Number(sh(`stat -c %s /home/admin/.big.bin.chj-part-${lastJob().id}`)); await sleep(500); assert.equal(Number(sh(`stat -c %s /home/admin/.big.bin.chj-part-${lastJob().id}`)), partSize, "no data written while paused"); assert.ok(offset < 256 * 1024 * 1024 && partSize >= offset); transfers.resume("fm", [lastJob().id]); await until(() => lastJob().state === "completed", 300000, "resume"); assert.equal(sh("sha256sum /home/admin/big.bin | cut -d' ' -f1"), bigHash); t.diagnostic(`paused at ${offset} of ${256 * 1024 * 1024}; average ${lastJob().averageSpeed} B/s`); }); await t.test("SSH outage interrupts, reconnect resumes safely", async () => { uploadSelection = [huge]; const plan = await transfers.planUpload("fm", "admin", "/home/admin"); transfers.updateSettings("fm", { autoRetry: true }); await transfers.start("fm", plan.planId); await until(() => lastJob()?.transferred > 64 * 1024 * 1024, 120000, "progress"); sh("pkill -KILL -u admin || true", { allowFailure: true }); // drops the SSH connection (sshd-session runs as the user) await until(() => ["interrupted", "reconnecting"].includes(lastJob().state), 20000, "interruption"); const offset = lastJob().transferred; assert.ok(offset < 1024 * 1024 * 1024, "interrupted before the end"); assert.equal(sh("test -e /home/admin/huge.bin && echo yes || echo no"), "no", "partial data never published"); await connect("admin"); await until(() => lastJob().state === "completed", 300000, "auto resume"); assert.equal(remoteHash("/home/admin/huge.bin"), hugeHash); t.diagnostic(`interrupted at ${offset}, resumed automatically after reconnect`); sh("rm -f /home/admin/huge.bin"); }); await t.test("server restart and application restart: the job is offered for resume and completes", async () => { uploadSelection = [huge]; const plan = await transfers.planUpload("fm", "admin", "/home/admin"); await transfers.start("fm", plan.planId); await until(() => lastJob()?.transferred > 64 * 1024 * 1024, 120000, "progress"); const id = lastJob().id; transfers.shutdown(); // application closes await manager.disconnectAll("test"); docker(["restart", "-t", "1", container]); // remote SSH server restarts port = Number(docker(["port", container, "22/tcp"]).trim().split(":").pop()); for (const profile of profiles.profiles.values()) profile.port = port; await until(() => spawnSync("docker", ["exec", container, "pgrep", "sshd"]).status === 0, 20000, "sshd after restart"); await sleep(500); transfers = new TransferManager({ sessionManager: manager, remoteFileService: files, storageRoot: storage, retryDelaysMs: [500, 1000, 2000] }); const restored = jobsOf().find((entry) => entry.id === id); assert.equal(restored.state, "interrupted"); assert.equal(restored.error.code, "APP_RESTARTED"); assert.equal(restored.actions.resume, true); for (const session of ["root", "admin", "nopass", "plain"]) await connect(session); transfers.resume("fm", [id]); await until(() => jobsOf().find((entry) => entry.id === id).state === "completed", 300000, "resume after restarts"); assert.equal(remoteHash("/home/admin/huge.bin"), hugeHash); t.diagnostic(`restored offset ${restored.transferred} of ${restored.size}`); }); await t.test("cancel removes only the partial data and keeps the existing target", async () => { sh("printf 'keep\\n' > /home/admin/huge.bin"); uploadSelection = [huge]; const plan = await transfers.planUpload("fm", "admin", "/home/admin"); await transfers.start("fm", plan.planId, { conflict: "overwrite" }); await until(() => lastJob()?.transferred > 32 * 1024 * 1024, 120000, "progress"); const id = lastJob().id; assert.equal(sh(`test -e /home/admin/.huge.bin.chj-part-${id} && echo yes || echo no`), "yes"); await transfers.cancel("fm", [id]); await until(() => lastJob().state === "cancelled", 30000, "cancelled"); assert.equal(sh("cat /home/admin/huge.bin"), "keep"); assert.equal(sh(`test -e /home/admin/.huge.bin.chj-part-${id} && echo yes || echo no`), "no", "only the job's partial file is removed"); }); await t.test("a local file changed while paused is detected; several files show individual and total progress", async () => { const changing = path.join(work, "changing.bin"); fs.copyFileSync(big, changing); uploadSelection = [changing]; const plan = await transfers.planUpload("fm", "admin", "/home/admin"); await transfers.start("fm", plan.planId); await until(() => lastJob()?.transferred > 8 * 1024 * 1024, 120000, "progress"); transfers.pause("fm", [lastJob().id]); await until(() => lastJob().state === "paused", 30000, "paused"); fs.appendFileSync(changing, "changed"); transfers.resume("fm", [lastJob().id]); await until(() => lastJob().state === "failed", 30000, "failed"); assert.equal(lastJob().error.code, "FILE_SOURCE_CHANGED"); await transfers.cancel("fm", [lastJob().id]); const dir = path.join(work, "many"); fs.mkdirSync(dir); for (let index = 0; index < 6; index += 1) fs.writeFileSync(path.join(dir, `f${index}.bin`), crypto.randomBytes((index + 1) * 1024 * 1024)); uploadSelection = [dir]; transfers.updateSettings("fm", { maxConcurrent: 3 }); const many = await transfers.planUpload("fm", "admin", "/home/admin"); let peak = 0; const listener = (update) => { peak = Math.max(peak, update.summary.active); }; transfers.on("update", listener); await transfers.start("fm", many.planId); await until(() => jobsOf().filter((entry) => entry.batchId === many.planId).every((entry) => entry.state === "completed"), 120000, "batch"); transfers.removeListener("update", listener); assert.ok(peak <= 3 && peak >= 2, `concurrency ${peak}`); for (let index = 0; index < 6; index += 1) assert.equal(sh(`stat -c %s /home/admin/many/f${index}.bin`), String((index + 1) * 1024 * 1024)); }); });