"use strict"; // File Manager privileged editing and Transfer Manager on ONE isolated test VM (Linux or Windows) over real SSH. // Same environment as linuxVmIntegration.test.js: CHJ_VM_HOST, CHJ_VM_FINGERPRINT (SHA256:...), CHJ_VM_KEY, // CHJ_VM_USER (default ai), CHJ_VM_SUDO_PASSWORD or CHJ_VM_SUDO_PASSWORD_FILE + CHJ_VM_SUDO_PASSWORD_LINE // (Linux only; read at runtime, never printed). Never point this at a production server. Everything is created // below /etc/chj-it-, ~/chj-it- and the account's ~/ch-j-sm/transfers and removed afterwards. // Not covered here (needs extra accounts or disruptive actions on a shared VM): direct root login, NOPASSWD and // no-sudo accounts, a full filesystem and a reboot of the SSH server; see filesIntegration.test.js (Docker). 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 } = 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 { detectPlatform } = require("../src/main/sessions/platformDetect"); const env = process.env; const enabled = Boolean(env.CHJ_VM_HOST && env.CHJ_VM_FINGERPRINT && env.CHJ_VM_KEY); const skip = enabled ? false : "Set CHJ_VM_* to run against an isolated test VM."; const readSecret = () => env.CHJ_VM_SUDO_PASSWORD || (env.CHJ_VM_SUDO_PASSWORD_FILE ? fs.readFileSync(env.CHJ_VM_SUDO_PASSWORD_FILE, "utf8").split("\n")[Number(env.CHJ_VM_SUDO_PASSWORD_LINE || 1) - 1].split("=").slice(1).join("=").trim() : ""); const vmUser = env.CHJ_VM_USER || "ai"; const host = env.CHJ_VM_HOST; const run = crypto.randomBytes(4).toString("hex"); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sha = (data) => crypto.createHash("sha256").update(data).digest("hex"); async function until(predicate, timeout = 120000, 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); } } function bigFile(file, mib) { const fd = fs.openSync(file, "w"); const block = crypto.randomBytes(1024 * 1024); for (let index = 0; index < mib; index += 1) fs.writeSync(fd, block); fs.closeSync(fd); return sha(fs.readFileSync(file)); } test(`File Manager and transfers on an isolated test VM (${host || "not configured"})`, { skip, timeout: 1800000 }, async (t) => { const work = fs.mkdtempSync(path.join(os.tmpdir(), "chj-files-vm-")); t.after(() => fs.rmSync(work, { recursive: true, force: true })); const knownHosts = path.join(work, "known_hosts"); execFileSync("sh", ["-c", `ssh-keyscan -t ed25519 ${host} > '${knownHosts}' 2>/dev/null`]); assert.equal(execFileSync("ssh-keygen", ["-lf", knownHosts], { encoding: "utf8" }).split(" ")[1], env.CHJ_VM_FINGERPRINT, "documented host key"); const hostKeys = new Map(); const profileService = { get: () => ({ id: "vm", label: "vm", host, port: 22, username: vmUser, authMethod: "privateKey", privateKeyPath: env.CHJ_VM_KEY }), getHostKey: (h, p) => hostKeys.get(`${h}:${p}`) || null, trustHostKey: (h, p, fp) => hostKeys.set(`${h}:${p}`, fp), getStoredPassword: () => "", markUsed() {} }; const manager = new SessionManager({ profileService, latencyMonitor: { start: () => () => {} } }); const connect = async (sessionId) => { try { await manager.connect({ sessionId, profileId: "vm" }); } catch (error) { if (error.code !== "HOST_KEY_UNKNOWN") throw error; assert.equal(`SHA256:${Buffer.from(error.fingerprint, "hex").toString("base64").replace(/=+$/, "")}`, env.CHJ_VM_FINGERPRINT); manager.trustPending({ sessionId, profileId: "vm", fingerprint: error.fingerprint }); await manager.connect({ sessionId, profileId: "vm" }); } }; await connect("vm"); await connect("xfer"); // node:test runs after-hooks in registration order: data cleanup is registered first, the disconnect last. const cleanups = []; t.after(async () => { transfers.shutdown(); try { for (const cleanup of cleanups) await cleanup(); } finally { await manager.disconnectAll("test"); } }); 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] }); const lastJob = () => transfers.list("fm").jobs.at(-1); const platform = await detectPlatform(manager, "vm"); t.diagnostic(`platform=${platform.os} ${platform.caption || ""}`.trim()); if (platform.os === "windows") { const home = (await new Promise((resolve, reject) => manager.openSftp("vm").then((sftp) => sftp.realpath(".", (error, value) => { sftp.end(); error ? reject(error) : resolve(value); }), reject))); const dir = path.posix.join(home, `chj-it-${run}`); await files.mkdir("vm", dir); cleanups.push(async () => { if (!manager.list().some((entry) => entry.sessionId === "vm")) await connect("vm"); await files.removeMany("vm", [dir], true); assert.equal((await files.list("vm", home)).some((entry) => entry.name === `chj-it-${run}`), false, "Windows test data removed"); }); await t.test("Windows: listing, editor limitation is reported clearly", async () => { assert.ok((await files.list("vm", home)).some((entry) => entry.name === `chj-it-${run}`)); await files.createFile("vm", `${dir}/note.txt`); await assert.rejects(() => files.readText("vm", `${dir}/note.txt`), { code: "FEATURE_PLATFORM_UNSUPPORTED" }); }); await t.test("Windows: upload, pause/resume, download with verification, cancel", async () => { const local = path.join(work, "win.bin"); const digest = bigFile(local, 64); uploadSelection = [local]; const plan = await transfers.planUpload("fm", "xfer", dir); assert.equal(plan.mode, "direct"); 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"); const offset = lastJob().transferred; transfers.resume("fm", [lastJob().id]); await until(() => ["completed", "failed"].includes(lastJob().state), 300000, "upload"); assert.equal(lastJob().state, "completed", JSON.stringify(lastJob().error)); assert.equal(lastJob().verification.method, "sha256", "Get-FileHash verification on Windows"); t.diagnostic(`Windows upload paused at ${offset}, verification ${lastJob().verification?.method}`); const again = await transfers.planUpload("fm", "xfer", dir); await transfers.start("fm", again.planId, { conflict: "overwrite" }); await until(() => ["completed", "failed"].includes(lastJob().state), 60000, "overwrite attempt"); assert.equal(lastJob().state, "failed"); assert.equal(lastJob().error.code, "FEATURE_PLATFORM_UNSUPPORTED", "existing files are not replaced without metadata support"); const downloads = fs.mkdtempSync(path.join(work, "dl-")); downloadTarget = downloads; const download = await transfers.planDownload("fm", "xfer", [`${dir}/win.bin`]); await transfers.start("fm", download.planId); await until(() => ["completed", "failed"].includes(lastJob().state), 300000, "download"); assert.equal(lastJob().state, "completed", JSON.stringify(lastJob().error)); assert.equal(sha(fs.readFileSync(path.join(downloads, "win.bin"))), digest); assert.equal(lastJob().verification.method, "sha256"); assert.equal(lastJob().verification.matched, true); uploadSelection = [path.join(work, "win-cancel.bin")]; fs.copyFileSync(local, uploadSelection[0]); const cancel = await transfers.planUpload("fm", "xfer", dir); await transfers.start("fm", cancel.planId); await until(() => lastJob()?.transferred > 4 * 1024 * 1024, 120000, "progress"); await transfers.cancel("fm", [lastJob().id]); await until(() => lastJob().state === "cancelled", 30000, "cancelled"); const names = (await files.list("vm", dir)).map((entry) => entry.name); assert.equal(names.some((name) => name.includes("chj-part")), false); assert.equal(names.includes("win-cancel.bin"), false); }); return; } const sudoPassword = readSecret(); assert.ok(sudoPassword, "sudo password configured for Linux VMs"); const auth = { sudo: true, sudoPassword }; const asRoot = (script, args = []) => executor.run("vm", { script: `printf '\\036CHJ_READY\\n'\n${script}`, args, sudoPassword, purpose: "files-vm-test", timeoutMs: 60000 }) .then((result) => result.stdout.trim()); const base = `/etc/chj-it-${run}`; const home = (await asRoot(`getent passwd "$1" | cut -d: -f6`, [vmUser])) || `/home/${vmUser}`; const userDir = `${home}/chj-it-${run}`; cleanups.push(async () => { if (!manager.list().some((entry) => entry.sessionId === "vm")) await connect("vm"); await asRoot(`rm -rf -- "$1" "$2"; rm -f -- "$3"/ch-j-sm/transfers/*.part "$3"/ch-j-sm/.*.tmp "$3"/ch-j-sm/.*.json; rmdir "$3"/ch-j-sm/transfers "$3"/ch-j-sm 2>/dev/null; true`, [base, userDir, home]); assert.equal(await asRoot(`test -e "$1" || test -e "$2" && echo left || echo clean`, [base, userDir]), "clean", "test data removed"); }); const acl = await asRoot(String.raw`set -e umask 022; mkdir "$1" "$1/sites-available" "$1/sites-enabled"; mkdir -m 0755 "$2"; chown "$3" "$2" printf 'worker_processes 1;\n' > "$1/nginx.conf" printf 'server { listen 80; }\n' > "$1/sites-available/example.conf"; ln -s ../sites-available/example.conf "$1/sites-enabled/example.conf" printf 'secret=1\n' > "$1/secret.conf"; chmod 0600 "$1/secret.conf" printf 'shared\n' > "$1/hard.conf"; ln "$1/hard.conf" "$1/hard-link.conf" python3 - "$1/nginx.conf" <<'PY' import os, struct, sys p = sys.argv[1] os.setxattr(p, 'user.chj', b'kept') entries = [(1, 6, 0xffffffff), (2, 4, 1), (4, 4, 0xffffffff), (16, 4, 0xffffffff), (32, 4, 0xffffffff)] try: os.setxattr(p, 'system.posix_acl_access', struct.pack(' asRoot(String.raw`python3 - "$1" <<'PY' import base64, os, stat, sys s = os.lstat(sys.argv[1]); keys = sorted(os.listxattr(sys.argv[1], follow_symlinks=False)) print(s.st_uid, s.st_gid, oct(stat.S_IMODE(s.st_mode)), [(k, base64.b64encode(os.getxattr(sys.argv[1], k, follow_symlinks=False)).decode()) for k in keys if not k.startswith('security.')]) PY`, [file]); const read = (file) => asRoot(`cat -- "$1"`, [file]); await t.test("sudo account: clear refusal, password required/incorrect, then privileged read", async () => { await assert.rejects(() => files.readText("vm", `${base}/secret.conf`), { code: "FILE_PERMISSION_DENIED" }); executor.forgetPassword("vm"); await assert.rejects(() => files.readText("vm", `${base}/secret.conf`, { sudo: true }), { code: "SUDO_PASSWORD_REQUIRED" }); await assert.rejects(() => files.readText("vm", `${base}/secret.conf`, { sudo: true, sudoPassword: "definitely-wrong" }), { code: "SUDO_PASSWORD_INCORRECT" }); assert.equal((await files.readText("vm", `${base}/secret.conf`, auth)).text, "secret=1\n"); assert.equal((await files.privileges("vm")).mode, "sudo-password"); }); await t.test("privileged save in /etc keeps owner, mode, xattr and ACL; nothing left behind", async () => { const before = await facts(`${base}/nginx.conf`); const opened = await files.readText("vm", `${base}/nginx.conf`); assert.equal(opened.directSave, false); const denied = await files.saveText("vm", `${base}/nginx.conf`, "worker_processes 2;\n", { editId: opened.editId }); assert.equal(denied.error.code, "FILE_PERMISSION_DENIED"); const saved = await files.saveText("vm", `${base}/nginx.conf`, "worker_processes 2;\n", { editId: opened.editId, ...auth, rememberSudo: true }); assert.equal(saved.ok, true, JSON.stringify(saved.error)); assert.equal(await read(`${base}/nginx.conf`), "worker_processes 2;"); assert.equal(await facts(`${base}/nginx.conf`), before); assert.equal(await asRoot(`ls -a "$1" | grep -c chj || true`, [base]), "0"); }); await t.test("symlink kept, hardlink refused, external change detected", async () => { const opened = await files.readText("vm", `${base}/sites-enabled/example.conf`); assert.equal(opened.resolvedPath, `${base}/sites-available/example.conf`); assert.equal((await files.saveText("vm", `${base}/sites-enabled/example.conf`, "server { listen 8080; }\n", { editId: opened.editId, sudo: true })).ok, true); assert.equal(await asRoot(`readlink "$1"`, [`${base}/sites-enabled/example.conf`]), "../sites-available/example.conf"); assert.equal(await read(`${base}/sites-available/example.conf`), "server { listen 8080; }"); await assert.rejects(() => files.readText("vm", `${base}/hard.conf`, { sudo: true }), { code: "FILE_HARDLINK_UNSUPPORTED" }); const again = await files.readText("vm", `${base}/nginx.conf`); await asRoot(`printf 'external\\n' >> "$1"`, [`${base}/nginx.conf`]); const conflict = await files.saveText("vm", `${base}/nginx.conf`, "mine\n", { editId: again.editId, sudo: true }); assert.equal(conflict.error.code, "FILE_CONFLICT"); assert.match(await read(`${base}/nginx.conf`), /external$/); }); await t.test("privileged create, mkdir, rename and remove; protected paths refused", async () => { await files.mkdir("vm", `${base}/conf.d`, { sudo: true }); await files.createFile("vm", `${base}/conf.d/a.conf`, { sudo: true }); await files.rename("vm", `${base}/conf.d/a.conf`, `${base}/conf.d/b.conf`, { sudo: true }); await assert.rejects(() => files.createFile("vm", `${base}/conf.d/b.conf`, { sudo: true }), { code: "FILE_EXISTS" }); assert.equal(await asRoot(`stat -c '%u:%g:%a' "$1"`, [`${base}/conf.d/b.conf`]), "0:0:644"); await files.removeMany("vm", [`${base}/conf.d`], true, { sudo: true }); await assert.rejects(() => files.removeMany("vm", ["/etc"], true, { sudo: true }), { code: "FILE_PROTECTED_PATH" }); }); const big = path.join(work, "big.bin"); const bigHash = bigFile(big, 128); await t.test("upload/download of a regular file and privileged upload/download in /etc", async () => { uploadSelection = [big]; const plan = await transfers.planUpload("fm", "xfer", userDir); assert.equal(plan.mode, "direct"); await transfers.start("fm", plan.planId); await until(() => ["completed", "failed"].includes(lastJob()?.state), 300000, "upload"); assert.equal(lastJob().state, "completed", JSON.stringify(lastJob().error)); assert.equal(await asRoot(`sha256sum "$1" | cut -d' ' -f1`, [`${userDir}/big.bin`]), bigHash); const conf = path.join(work, "nginx.conf"); fs.writeFileSync(conf, "worker_processes auto;\n".repeat(2000)); await asRoot(`printf 'worker_processes 1;\\n' > "$1"`, [`${base}/nginx.conf`]); const before = await facts(`${base}/nginx.conf`); uploadSelection = [conf]; const privileged = await transfers.planUpload("fm", "xfer", base); assert.equal(privileged.mode, "privileged"); assert.equal(privileged.conflictCount, 1); await transfers.start("fm", privileged.planId, { conflict: "overwrite", sudoPassword }); await until(() => ["completed", "failed", "interrupted"].includes(lastJob()?.state), 120000, "privileged upload"); assert.equal(lastJob().state, "completed", JSON.stringify(lastJob().error)); assert.equal(await asRoot(`sha256sum "$1" | cut -d' ' -f1`, [`${base}/nginx.conf`]), sha(fs.readFileSync(conf))); assert.equal(await facts(`${base}/nginx.conf`), before, "metadata of the replaced /etc file preserved"); const downloads = fs.mkdtempSync(path.join(work, "dl-")); downloadTarget = downloads; const download = await transfers.planDownload("fm", "xfer", [`${base}/secret.conf`], { sudo: true }); await transfers.start("fm", download.planId, { sudo: true, sudoPassword }); await until(() => ["completed", "failed", "interrupted"].includes(lastJob()?.state), 120000, "privileged download"); assert.equal(lastJob().state, "completed", JSON.stringify(lastJob().error)); assert.equal(fs.readFileSync(path.join(downloads, "secret.conf"), "utf8"), "secret=1\n"); const regular = await transfers.planDownload("fm", "xfer", [`${userDir}/big.bin`]); await transfers.start("fm", regular.planId); await until(() => ["completed", "failed"].includes(lastJob()?.state), 300000, "download"); assert.equal(sha(fs.readFileSync(path.join(downloads, "big.bin"))), bigHash); assert.equal(lastJob().verification.matched, true); }); await t.test("pause/resume continues at the confirmed offset", async () => { await asRoot(`rm -f "$1"`, [`${userDir}/big.bin`]); uploadSelection = [big]; const plan = await transfers.planUpload("fm", "xfer", userDir); 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"); const offset = lastJob().transferred; const part = `${userDir}/.big.bin.chj-part-${lastJob().id}`; const size = await asRoot(`stat -c %s "$1"`, [part]); await sleep(800); assert.equal(await asRoot(`stat -c %s "$1"`, [part]), size, "no data written while paused"); transfers.resume("fm", [lastJob().id]); await until(() => ["completed", "failed"].includes(lastJob().state), 300000, "resume"); assert.equal(lastJob().state, "completed", JSON.stringify(lastJob().error)); assert.equal(await asRoot(`sha256sum "$1" | cut -d' ' -f1`, [`${userDir}/big.bin`]), bigHash); t.diagnostic(`paused at ${offset}, average ${lastJob().averageSpeed} B/s`); }); await t.test("SSH outage of the transfer connection; reconnect resumes", async () => { // 512 MiB sparse file: long enough on a LAN for the outage to hit the running data transfer. const outage = path.join(work, "outage.bin"); fs.closeSync(fs.openSync(outage, "w")); fs.truncateSync(outage, 512 * 1024 * 1024); const outageHash = sha(fs.readFileSync(outage)); uploadSelection = [outage]; const plan = await transfers.planUpload("fm", "xfer", userDir); await transfers.start("fm", plan.planId); await until(() => lastJob()?.transferred > 8 * 1024 * 1024, 120000, "progress"); const port = manager.sessions.get("xfer").client._sock.localPort; // Kill only the server side of this one TCP connection (other sessions stay up). await asRoot(String.raw`pids=$(ss -Htnp 2>/dev/null | awk -v p=":$1" '$5 ~ p"$"' | grep -o 'pid=[0-9]*' | cut -d= -f2) [ -n "$pids" ] || pids=$(netstat -tnp 2>/dev/null | awk -v p=":$1" '$5 ~ p"$" {split($7, a, "/"); print a[1]}') for p in $pids; do pids="$pids $(pgrep -P "$p" 2>/dev/null)"; done [ -n "$pids" ] && kill -KILL $pids; echo "port=$1 pids=" $pids`, [String(port)]).then((out) => t.diagnostic(`outage: ${out} at ${lastJob().transferred}`)); await until(() => ["interrupted", "reconnecting", "completed"].includes(lastJob().state), 60000, "interruption"); assert.notEqual(lastJob().state, "completed", "interrupted before the end"); const offset = lastJob().transferred; assert.ok(offset < 512 * 1024 * 1024, "outage hit the data transfer"); assert.equal(await asRoot(`test -e "$1" && echo yes || echo no`, [`${userDir}/outage.bin`]), "no", "partial data never published"); await connect("xfer"); await until(() => ["completed", "failed"].includes(lastJob().state), 300000, "auto resume"); assert.equal(lastJob().state, "completed", JSON.stringify(lastJob().error)); assert.equal(await asRoot(`sha256sum "$1" | cut -d' ' -f1`, [`${userDir}/outage.bin`]), outageHash); await asRoot(`rm -f "$1"`, [`${userDir}/outage.bin`]); t.diagnostic(`interrupted at ${offset}`); }); await t.test("application restart offers resume; cancel removes only partial data", async () => { await asRoot(`rm -f "$1"`, [`${userDir}/big.bin`]); uploadSelection = [big]; const plan = await transfers.planUpload("fm", "xfer", userDir); await transfers.start("fm", plan.planId); await until(() => lastJob()?.transferred > 8 * 1024 * 1024, 120000, "progress"); const id = lastJob().id; transfers.shutdown(); transfers = new TransferManager({ sessionManager: manager, remoteFileService: files, storageRoot: storage, retryDelaysMs: [500, 1000, 2000] }); const restored = transfers.list("fm").jobs.find((entry) => entry.id === id); assert.equal(restored.state, "interrupted"); assert.equal(restored.error.code, "APP_RESTARTED"); transfers.resume("fm", [id]); await until(() => ["completed", "failed"].includes(transfers.list("fm").jobs.find((entry) => entry.id === id).state), 300000, "resume after restart"); assert.equal(await asRoot(`sha256sum "$1" | cut -d' ' -f1`, [`${userDir}/big.bin`]), bigHash); await asRoot(`printf 'keep\\n' > "$1"; chown "$2" "$1"`, [`${userDir}/big.bin`, vmUser]); const cancel = await transfers.planUpload("fm", "xfer", userDir); await transfers.start("fm", cancel.planId, { conflict: "overwrite" }); await until(() => lastJob()?.transferred > 4 * 1024 * 1024, 120000, "progress"); const cancelId = lastJob().id; await transfers.cancel("fm", [cancelId]); await until(() => lastJob().state === "cancelled", 30000, "cancelled"); assert.equal(await read(`${userDir}/big.bin`), "keep"); assert.equal(await asRoot(`test -e "$1" && echo yes || echo no`, [`${userDir}/.big.bin.chj-part-${cancelId}`]), "no"); }); await t.test("several files concurrently; a changed source is detected", async () => { const dir = path.join(work, "many"); fs.mkdirSync(dir); for (let index = 0; index < 5; index += 1) fs.writeFileSync(path.join(dir, `f${index}.bin`), crypto.randomBytes((index + 1) * 2 * 1024 * 1024)); uploadSelection = [dir]; transfers.updateSettings("fm", { maxConcurrent: 3 }); const plan = await transfers.planUpload("fm", "xfer", userDir); let peak = 0; const listener = (update) => { peak = Math.max(peak, update.summary.active); }; transfers.on("update", listener); await transfers.start("fm", plan.planId); await until(() => transfers.list("fm").jobs.filter((entry) => entry.batchId === plan.planId).every((entry) => entry.state === "completed"), 300000, "batch"); transfers.removeListener("update", listener); assert.ok(peak >= 2 && peak <= 3, `concurrency ${peak}`); const changing = path.join(work, "changing.bin"); fs.copyFileSync(big, changing); uploadSelection = [changing]; const changed = await transfers.planUpload("fm", "xfer", userDir); await transfers.start("fm", changed.planId); await until(() => lastJob()?.transferred > 4 * 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", 60000, "failed"); assert.equal(lastJob().error.code, "FILE_SOURCE_CHANGED"); await transfers.cancel("fm", [lastJob().id]); }); });