공개된 소스
소스 ZIP 다운로드
CH-J Server Manager
특정 애플리케이션 릴리스의 디렉터리와 파일을 살펴보세요.
CH-J / Server Manager
377 개 파일
core-20261019T111616Z-160a00bfcddc
추가됨: · 빌드됨:
1
"use strict";3
// End-to-end tests of privileged editing and SFTP transfers against a disposable Docker container4
// over real SSH (sshd, sftp-server, sudo, python3, ACL/xattr). Never point this at a real server.5
// Run explicitly: CHJ_FILES_INTEGRATION=1 CHJ_FILES_IMAGE=chj-files-it-debian node --test test/filesIntegration.test.js6
// Images: test/fixtures/filesIntegration (Dockerfile.debian with BASE=debian/ubuntu, Dockerfile.rocky).7
const test = require("node:test");8
const assert = require("node:assert/strict");9
const crypto = require("node:crypto");10
const fs = require("node:fs");11
const os = require("node:os");12
const path = require("node:path");13
const { execFileSync, spawnSync } = require("node:child_process");14
const { SessionManager } = require("../src/main/sessions/sessionManager");15
const { PrivilegedExecutor } = require("../src/main/sessions/privilegedExec");16
const { RemoteFileService } = require("../src/main/files/remoteFileService");17
const { TransferManager } = require("../src/main/files/transferManager");19
const enabled = process.env.CHJ_FILES_INTEGRATION === "1";20
const image = process.env.CHJ_FILES_IMAGE || "chj-files-it-debian";21
const skip = enabled ? false : "Set CHJ_FILES_INTEGRATION=1 to run against a disposable Docker container.";22
const container = `chj-files-it-${process.pid}`;23
const ADMIN = { sudoPassword: "AdminPass-1" };25
function docker(args) { return execFileSync("docker", args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }); }26
function sh(command, { allowFailure = false } = {}) {27
const result = spawnSync("docker", ["exec", container, "sh", "-c", command], { encoding: "utf8" });28
if (!allowFailure && result.status !== 0) throw new Error(`container command failed (${result.status}): ${command}\n${result.stderr}`);29
return result.stdout.trim();30
}31
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));32
async function until(predicate, timeout = 60000, label = "condition") {33
const started = Date.now();34
while (!(await predicate())) { if (Date.now() - started > timeout) throw new Error(`Timed out waiting for ${label}`); await sleep(50); }35
}37
class Profiles {38
constructor() { this.profiles = new Map(); this.hostKeys = new Map(); this.passwords = new Map(); }39
add(profile, password) { this.profiles.set(profile.id, profile); if (password) this.passwords.set(profile.id, password); }40
get(id) { const profile = this.profiles.get(id); if (!profile) throw new Error("Profile not found"); return profile; }41
getHostKey(host, port) { return this.hostKeys.get(`${host}:${port}`) || null; }42
trustHostKey(host, port, fingerprint) { this.hostKeys.set(`${host}:${port}`, fingerprint); return { trusted: true }; }43
getStoredPassword(id) { return this.passwords.get(id) || ""; }44
markUsed() {}45
}47
function freePort() {48
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)); }); });49
}51
test(`File Manager privileged editing and transfers on ${image}`, { skip, timeout: 1800000 }, async (t) => {52
// A fixed host port keeps the server identity stable across the container (SSH server) restart.53
const hostPort = await freePort();54
docker(["run", "-d", "--rm", "--name", container, "--tmpfs", "/srv/tiny:size=64k,mode=0755", "-p", `127.0.0.1:${hostPort}:22`, image]);55
t.after(() => { try { docker(["rm", "-f", container]); } catch {} });56
let port = Number(docker(["port", container, "22/tcp"]).trim().split(":").pop());57
await until(() => spawnSync("docker", ["exec", container, "test", "-s", "/run/sshd.pid"]).status === 0 || spawnSync("docker", ["exec", container, "pgrep", "sshd"]).status === 0, 20000, "sshd");58
const work = fs.mkdtempSync(path.join(os.tmpdir(), "chj-files-it-"));59
t.after(() => fs.rmSync(work, { recursive: true, force: true }));60
for (const name of ["root", "nopass"]) execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", name, "-f", path.join(work, name)]);61
const pub = (name) => fs.readFileSync(path.join(work, `${name}.pub`), "utf8").trim();62
sh(`mkdir -p /root/.ssh && printf '%s\\n' '${pub("root")}' > /root/.ssh/authorized_keys && chmod 700 /root/.ssh && chmod 600 /root/.ssh/authorized_keys`);63
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`);64
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)");65
await sleep(700);66
// Test fixtures in /etc: owners, modes, ACL, xattr, symlink layout like nginx, a hardlink, a root-only file.67
sh(`set -e; mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled68
printf 'worker_processes 1;\\n' > /etc/nginx/nginx.conf; chmod 0644 /etc/nginx/nginx.conf69
setfacl -m u:plain:r-- /etc/nginx/nginx.conf; setfattr -n user.chj -v kept /etc/nginx/nginx.conf70
printf 'server { listen 80; }\\n' > /etc/nginx/sites-available/example.conf71
ln -s ../sites-available/example.conf /etc/nginx/sites-enabled/example.conf72
printf 'secret=1\\n' > /etc/chj-secret.conf; chmod 0600 /etc/chj-secret.conf73
printf 'shared\\n' > /etc/chj-hard.conf; ln /etc/chj-hard.conf /etc/chj-hard-link.conf74
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`);75
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' ' '`);76
t.diagnostic(`image=${image} ${sh("sshd -V 2>&1 | head -n 1; python3 --version", { allowFailure: true })}`);78
const profiles = new Profiles();79
const base = { host: "127.0.0.1", port, label: "it" };80
profiles.add({ ...base, id: "admin", username: "admin", authMethod: "password" }, "AdminPass-1");81
profiles.add({ ...base, id: "plain", username: "plain", authMethod: "password" }, "PlainPass-1");82
profiles.add({ ...base, id: "nopass", username: "nopass", authMethod: "privateKey", privateKeyPath: path.join(work, "nopass") });83
profiles.add({ ...base, id: "root", username: "root", authMethod: "privateKey", privateKeyPath: path.join(work, "root") });84
const manager = new SessionManager({ profileService: profiles, latencyMonitor: { start: () => () => {} } });85
const executor = new PrivilegedExecutor({ sessionManager: manager });86
manager.privilegedExecutor = executor;87
let uploadSelection = [];88
let downloadTarget = work;89
const files = new RemoteFileService({ sessionManager: manager, privilegedExecutor: executor,90
selectUploadPath: async () => ({ canceled: false, paths: uploadSelection }), selectDownloadPath: async (name) => ({ canceled: false, path: path.join(downloadTarget, name) }),91
selectDownloadDirectory: async () => ({ canceled: false, path: downloadTarget }) });92
const storage = path.join(work, "state");93
let transfers = new TransferManager({ sessionManager: manager, remoteFileService: files, storageRoot: storage, retryDelaysMs: [500, 1000, 2000] });94
t.after(() => { transfers.shutdown(); return manager.disconnectAll("test"); });95
const connect = async (id) => {96
try { await manager.connect({ sessionId: id, profileId: id }); }97
catch (error) {98
if (error.code !== "HOST_KEY_UNKNOWN") throw error;99
manager.trustPending({ sessionId: id, profileId: id, fingerprint: error.fingerprint });100
await manager.connect({ sessionId: id, profileId: id });101
}102
};103
for (const id of ["root", "admin", "nopass", "plain"]) await connect(id);104
const FINALISH = new Set(["completed", "failed", "cancelled", "interrupted", "paused"]);105
const jobsOf = () => transfers.list("fm").jobs;106
const lastJob = () => jobsOf().at(-1);108
await t.test("root reads and saves /etc directly, preserving owner, mode, ACL and xattr", async () => {109
const before = facts("/etc/nginx/nginx.conf");110
const mtime = sh("stat -c %Y /etc/nginx/nginx.conf");111
const opened = await files.readText("root", "/etc/nginx/nginx.conf");112
assert.equal(opened.text, "worker_processes 1;\n"); assert.equal(opened.directSave, true);113
await sleep(1100);114
const saved = await files.writeText("root", "/etc/nginx/nginx.conf", "worker_processes 2;\n", { editId: opened.editId });115
assert.equal(saved.status, "saved");116
assert.equal(sh("cat /etc/nginx/nginx.conf"), "worker_processes 2;");117
assert.equal(facts("/etc/nginx/nginx.conf"), before, "uid/gid/mode/ACL/xattr identical");118
assert.notEqual(sh("stat -c %Y /etc/nginx/nginx.conf"), mtime, "mtime reflects the new edit");119
assert.equal(sh("ls -a /etc/nginx | grep -c chj || true"), "0", "no temporary files left in /etc/nginx");120
const privileges = await files.privileges("root");121
assert.equal(privileges.mode, "root");122
});124
await t.test("root creates, renames and removes entries in /etc and reports real filesystem errors", async () => {125
await files.mkdir("root", "/etc/chj-dir");126
await files.createFile("root", "/etc/chj-dir/new.conf");127
await files.rename("root", "/etc/chj-dir/new.conf", "/etc/chj-dir/renamed.conf");128
await assert.rejects(() => files.createFile("root", "/etc/chj-dir/renamed.conf"), { code: "FILE_EXISTS" });129
await files.removeMany("root", ["/etc/chj-dir"], true);130
assert.equal(sh("test -e /etc/chj-dir && echo yes || echo no"), "no");131
await assert.rejects(() => files.removeMany("root", ["/etc"], true), { code: "FILE_PROTECTED_PATH" });132
});134
await t.test("sudo user: unprivileged access fails clearly, sudo with password reads and saves", async () => {135
await assert.rejects(() => files.readText("admin", "/etc/chj-secret.conf"), { code: "FILE_PERMISSION_DENIED" });136
await assert.rejects(() => files.readText("admin", "/etc/chj-secret.conf", { sudo: true }), { code: "SUDO_PASSWORD_REQUIRED" });137
await assert.rejects(() => files.readText("admin", "/etc/chj-secret.conf", { sudo: true, sudoPassword: "wrong" }), { code: "SUDO_PASSWORD_INCORRECT" });138
const secret = await files.readText("admin", "/etc/chj-secret.conf", { sudo: true, ...ADMIN });139
assert.equal(secret.text, "secret=1\n");140
const before = facts("/etc/nginx/nginx.conf");141
const opened = await files.readText("admin", "/etc/nginx/nginx.conf");142
assert.equal(opened.directSave, false, "UI is told that saving needs sudo");143
const denied = await files.saveText("admin", "/etc/nginx/nginx.conf", "worker_processes 3;\n", { editId: opened.editId });144
assert.equal(denied.ok, false); assert.equal(denied.error.code, "FILE_PERMISSION_DENIED");145
const wrong = await files.saveText("admin", "/etc/nginx/nginx.conf", "worker_processes 3;\n", { editId: opened.editId, sudo: true, sudoPassword: "wrong" });146
assert.equal(wrong.error.code, "SUDO_PASSWORD_INCORRECT"); assert.equal(wrong.error.status, "failed");147
assert.equal(sh("cat /etc/nginx/nginx.conf"), "worker_processes 2;", "original untouched after refused sudo");148
const saved = await files.saveText("admin", "/etc/nginx/nginx.conf", "worker_processes 3;\n", { editId: opened.editId, sudo: true, ...ADMIN, rememberSudo: true });149
assert.equal(saved.ok, true, JSON.stringify(saved.error));150
assert.equal(sh("cat /etc/nginx/nginx.conf"), "worker_processes 3;");151
assert.equal(facts("/etc/nginx/nginx.conf"), before);152
const again = await files.readText("admin", "/etc/chj-secret.conf", { sudo: true });153
assert.equal(again.text, "secret=1\n", "remembered sudo password reused from memory");154
});156
await t.test("NOPASSWD sudo works without a password; an account without sudo gets a clear error", async () => {157
const opened = await files.readText("nopass", "/etc/chj-secret.conf", { sudo: true });158
const saved = await files.saveText("nopass", "/etc/chj-secret.conf", "secret=2\n", { editId: opened.editId, sudo: true });159
assert.equal(saved.ok, true, JSON.stringify(saved.error));160
assert.equal(sh("stat -c '%u:%g:%a' /etc/chj-secret.conf"), "0:0:600");161
assert.equal((await files.privileges("nopass")).mode, "sudo-nopasswd");162
await assert.rejects(() => files.readText("plain", "/etc/chj-secret.conf", { sudo: true, sudoPassword: "PlainPass-1" }), { code: "SUDO_NOT_PERMITTED" });163
await assert.rejects(() => files.createFile("plain", "/etc/x.conf", { sudo: true, sudoPassword: "PlainPass-1" }), { code: "SUDO_NOT_PERMITTED" });164
});166
await t.test("editing through a symlink keeps the link and changes its target", async () => {167
const opened = await files.readText("admin", "/etc/nginx/sites-enabled/example.conf");168
assert.equal(opened.link, true); assert.equal(opened.resolvedPath, "/etc/nginx/sites-available/example.conf");169
const saved = await files.saveText("admin", "/etc/nginx/sites-enabled/example.conf", "server { listen 8080; }\n", { editId: opened.editId, sudo: true });170
assert.equal(saved.ok, true, JSON.stringify(saved.error));171
assert.equal(sh("readlink /etc/nginx/sites-enabled/example.conf"), "../sites-available/example.conf");172
assert.equal(sh("cat /etc/nginx/sites-available/example.conf"), "server { listen 8080; }");173
const listed = await files.list("admin", "/etc/nginx/sites-enabled");174
assert.equal(listed[0].type, "symlink"); assert.equal(listed[0].targetType, "file");175
});177
await t.test("hardlinked files are not replaced automatically", async () => {178
await assert.rejects(() => files.readText("root", "/etc/chj-hard.conf"), { code: "FILE_HARDLINK_UNSUPPORTED" });179
});181
await t.test("a change by another process is detected and nothing is overwritten", async () => {182
const opened = await files.readText("root", "/etc/nginx/nginx.conf");183
sh("printf 'external\\n' >> /etc/nginx/nginx.conf");184
const result = await files.saveText("root", "/etc/nginx/nginx.conf", "mine\n", { editId: opened.editId });185
assert.equal(result.error.code, "FILE_CONFLICT");186
assert.match(sh("cat /etc/nginx/nginx.conf"), /external$/);187
});189
await t.test("no space on the target filesystem leaves the original intact", async () => {190
const opened = await files.readText("root", "/srv/tiny/small.conf");191
const before = facts("/srv/tiny/small.conf");192
const result = await files.saveText("root", "/srv/tiny/small.conf", "x".repeat(200 * 1024), { editId: opened.editId });193
assert.equal(result.ok, false); assert.equal(result.error.code, "FILE_NO_SPACE");194
assert.equal(sh("cat /srv/tiny/small.conf"), "tiny");195
assert.equal(facts("/srv/tiny/small.conf"), before);196
assert.equal(sh("ls -a /srv/tiny | grep -c chj || true"), "0");197
});199
await t.test("upload and download of a regular file with verification", async () => {200
const local = path.join(work, "regular.bin"); const data = crypto.randomBytes(3 * 1024 * 1024 + 11); fs.writeFileSync(local, data);201
uploadSelection = [local];202
const plan = await transfers.planUpload("fm", "admin", "/home/admin");203
assert.equal(plan.mode, "direct");204
await transfers.start("fm", plan.planId);205
await until(() => lastJob()?.state === "completed", 60000, "upload");206
assert.equal(sh("sha256sum /home/admin/regular.bin | cut -d' ' -f1"), crypto.createHash("sha256").update(data).digest("hex"));207
assert.equal(sh("stat -c %U /home/admin/regular.bin"), "admin");208
const downloads = fs.mkdtempSync(path.join(work, "dl-")); downloadTarget = downloads;209
const download = await transfers.planDownload("fm", "admin", ["/home/admin/regular.bin"]);210
await transfers.start("fm", download.planId);211
await until(() => lastJob()?.state === "completed", 60000, "download");212
assert.deepEqual(fs.readFileSync(path.join(downloads, "regular.bin")), data);213
assert.equal(lastJob().verification.matched, true);214
});216
await t.test("upload into /etc publishes through sudo and keeps the existing file's metadata", async () => {217
const local = path.join(work, "nginx.conf"); fs.writeFileSync(local, "worker_processes auto;\n".repeat(1000));218
sh("printf 'worker_processes 1;\\n' > /etc/nginx/nginx.conf");219
const before = facts("/etc/nginx/nginx.conf");220
uploadSelection = [local];221
const plan = await transfers.planUpload("fm", "admin", "/etc/nginx");222
assert.equal(plan.mode, "privileged"); assert.equal(plan.conflictCount, 1);223
executor.forgetPassword("admin");224
await assert.rejects(() => transfers.start("fm", plan.planId, { conflict: "overwrite", sudoPassword: "wrong" }), { code: "SUDO_PASSWORD_INCORRECT" });225
await transfers.start("fm", plan.planId, { conflict: "overwrite", ...ADMIN });226
await until(() => ["completed", "failed", "interrupted"].includes(lastJob()?.state), 60000, "privileged upload");227
assert.equal(lastJob().state, "completed", JSON.stringify(lastJob().error));228
assert.equal(sh("sha256sum /etc/nginx/nginx.conf | cut -d' ' -f1"), crypto.createHash("sha256").update(fs.readFileSync(local)).digest("hex"));229
assert.equal(facts("/etc/nginx/nginx.conf"), before, "owner/mode/ACL/xattr preserved");230
assert.equal(lastJob().finalize.metadataPreserved, true);231
assert.equal(sh("ls -A /home/admin/ch-j-sm/transfers | wc -l"), "0", "staging cleaned");232
// A new file in a protected directory gets root ownership and the requested mode.233
const fresh = path.join(work, "fresh.conf"); fs.writeFileSync(fresh, "new\n"); uploadSelection = [fresh];234
const second = await transfers.planUpload("fm", "admin", "/etc/nginx/sites-available");235
await transfers.start("fm", second.planId, ADMIN);236
await until(() => lastJob()?.state === "completed", 60000, "new privileged upload");237
assert.equal(sh("stat -c '%u:%g:%a' /etc/nginx/sites-available/fresh.conf"), "0:0:644");238
});240
await t.test("privileged download of a root-only file", async () => {241
const downloads = fs.mkdtempSync(path.join(work, "dl-")); downloadTarget = downloads;242
await assert.rejects(() => transfers.planDownload("fm", "plain", ["/etc/chj-secret.conf"]).then(async (plan) => {243
await transfers.start("fm", plan.planId); await until(() => FINALISH.has(lastJob().state), 30000, "plain download");244
throw Object.assign(new Error(lastJob().error?.code), { code: lastJob().error?.code });245
}), { code: "FILE_PERMISSION_DENIED" });246
const plan = await transfers.planDownload("fm", "admin", ["/etc/chj-secret.conf"], { sudo: true });247
await transfers.start("fm", plan.planId, { sudo: true, ...ADMIN });248
await until(() => lastJob()?.state === "completed", 60000, "privileged download");249
assert.equal(fs.readFileSync(path.join(downloads, "chj-secret.conf"), "utf8"), "secret=2\n");250
assert.equal(fs.statSync(path.join(downloads, "chj-secret.conf")).mode & 0o777, 0o600);251
});253
const big = path.join(work, "big.bin");254
const fd = fs.openSync(big, "w"); const block = crypto.randomBytes(4 * 1024 * 1024);255
for (let index = 0; index < 64; index += 1) fs.writeSync(fd, block);256
fs.closeSync(fd);257
const bigHash = crypto.createHash("sha256").update(fs.readFileSync(big)).digest("hex");258
// 1 GiB sparse file: long enough to interrupt a running transfer on a fast local link.259
const huge = path.join(work, "huge.bin");260
fs.closeSync(fs.openSync(huge, "w")); fs.truncateSync(huge, 1024 * 1024 * 1024);261
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"))); });262
const remoteHash = (file) => sh(`sha256sum ${file} | cut -d' ' -f1`);264
await t.test("pause/resume continues at the confirmed offset over real SFTP", async () => {265
uploadSelection = [big];266
const plan = await transfers.planUpload("fm", "admin", "/home/admin");267
await transfers.start("fm", plan.planId, { conflict: "overwrite" });268
await until(() => lastJob()?.transferred > 32 * 1024 * 1024, 120000, "progress");269
transfers.pause("fm", [lastJob().id]);270
await until(() => lastJob().state === "paused", 30000, "paused");271
const offset = lastJob().transferred;272
const partSize = Number(sh(`stat -c %s /home/admin/.big.bin.chj-part-${lastJob().id}`));273
await sleep(500);274
assert.equal(Number(sh(`stat -c %s /home/admin/.big.bin.chj-part-${lastJob().id}`)), partSize, "no data written while paused");275
assert.ok(offset < 256 * 1024 * 1024 && partSize >= offset);276
transfers.resume("fm", [lastJob().id]);277
await until(() => lastJob().state === "completed", 300000, "resume");278
assert.equal(sh("sha256sum /home/admin/big.bin | cut -d' ' -f1"), bigHash);279
t.diagnostic(`paused at ${offset} of ${256 * 1024 * 1024}; average ${lastJob().averageSpeed} B/s`);280
});282
await t.test("SSH outage interrupts, reconnect resumes safely", async () => {283
uploadSelection = [huge];284
const plan = await transfers.planUpload("fm", "admin", "/home/admin");285
transfers.updateSettings("fm", { autoRetry: true });286
await transfers.start("fm", plan.planId);287
await until(() => lastJob()?.transferred > 64 * 1024 * 1024, 120000, "progress");288
sh("pkill -KILL -u admin || true", { allowFailure: true }); // drops the SSH connection (sshd-session runs as the user)289
await until(() => ["interrupted", "reconnecting"].includes(lastJob().state), 20000, "interruption");290
const offset = lastJob().transferred;291
assert.ok(offset < 1024 * 1024 * 1024, "interrupted before the end");292
assert.equal(sh("test -e /home/admin/huge.bin && echo yes || echo no"), "no", "partial data never published");293
await connect("admin");294
await until(() => lastJob().state === "completed", 300000, "auto resume");295
assert.equal(remoteHash("/home/admin/huge.bin"), hugeHash);296
t.diagnostic(`interrupted at ${offset}, resumed automatically after reconnect`);297
sh("rm -f /home/admin/huge.bin");298
});300
await t.test("server restart and application restart: the job is offered for resume and completes", async () => {301
uploadSelection = [huge];302
const plan = await transfers.planUpload("fm", "admin", "/home/admin");303
await transfers.start("fm", plan.planId);304
await until(() => lastJob()?.transferred > 64 * 1024 * 1024, 120000, "progress");305
const id = lastJob().id;306
transfers.shutdown(); // application closes307
await manager.disconnectAll("test");308
docker(["restart", "-t", "1", container]); // remote SSH server restarts309
port = Number(docker(["port", container, "22/tcp"]).trim().split(":").pop());310
for (const profile of profiles.profiles.values()) profile.port = port;311
await until(() => spawnSync("docker", ["exec", container, "pgrep", "sshd"]).status === 0, 20000, "sshd after restart");312
await sleep(500);313
transfers = new TransferManager({ sessionManager: manager, remoteFileService: files, storageRoot: storage, retryDelaysMs: [500, 1000, 2000] });314
const restored = jobsOf().find((entry) => entry.id === id);315
assert.equal(restored.state, "interrupted"); assert.equal(restored.error.code, "APP_RESTARTED"); assert.equal(restored.actions.resume, true);316
for (const session of ["root", "admin", "nopass", "plain"]) await connect(session);317
transfers.resume("fm", [id]);318
await until(() => jobsOf().find((entry) => entry.id === id).state === "completed", 300000, "resume after restarts");319
assert.equal(remoteHash("/home/admin/huge.bin"), hugeHash);320
t.diagnostic(`restored offset ${restored.transferred} of ${restored.size}`);321
});323
await t.test("cancel removes only the partial data and keeps the existing target", async () => {324
sh("printf 'keep\\n' > /home/admin/huge.bin");325
uploadSelection = [huge];326
const plan = await transfers.planUpload("fm", "admin", "/home/admin");327
await transfers.start("fm", plan.planId, { conflict: "overwrite" });328
await until(() => lastJob()?.transferred > 32 * 1024 * 1024, 120000, "progress");329
const id = lastJob().id;330
assert.equal(sh(`test -e /home/admin/.huge.bin.chj-part-${id} && echo yes || echo no`), "yes");331
await transfers.cancel("fm", [id]);332
await until(() => lastJob().state === "cancelled", 30000, "cancelled");333
assert.equal(sh("cat /home/admin/huge.bin"), "keep");334
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");335
});337
await t.test("a local file changed while paused is detected; several files show individual and total progress", async () => {338
const changing = path.join(work, "changing.bin"); fs.copyFileSync(big, changing);339
uploadSelection = [changing];340
const plan = await transfers.planUpload("fm", "admin", "/home/admin");341
await transfers.start("fm", plan.planId);342
await until(() => lastJob()?.transferred > 8 * 1024 * 1024, 120000, "progress");343
transfers.pause("fm", [lastJob().id]);344
await until(() => lastJob().state === "paused", 30000, "paused");345
fs.appendFileSync(changing, "changed");346
transfers.resume("fm", [lastJob().id]);347
await until(() => lastJob().state === "failed", 30000, "failed");348
assert.equal(lastJob().error.code, "FILE_SOURCE_CHANGED");349
await transfers.cancel("fm", [lastJob().id]);350
const dir = path.join(work, "many"); fs.mkdirSync(dir);351
for (let index = 0; index < 6; index += 1) fs.writeFileSync(path.join(dir, `f${index}.bin`), crypto.randomBytes((index + 1) * 1024 * 1024));352
uploadSelection = [dir];353
transfers.updateSettings("fm", { maxConcurrent: 3 });354
const many = await transfers.planUpload("fm", "admin", "/home/admin");355
let peak = 0; const listener = (update) => { peak = Math.max(peak, update.summary.active); };356
transfers.on("update", listener);357
await transfers.start("fm", many.planId);358
await until(() => jobsOf().filter((entry) => entry.batchId === many.planId).every((entry) => entry.state === "completed"), 120000, "batch");359
transfers.removeListener("update", listener);360
assert.ok(peak <= 3 && peak >= 2, `concurrency ${peak}`);361
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));362
});363
});SHA-256
37a18a738da28609ed613121c40823aec6622e83a84446d719303600baf954d8CH-J Proprietary Software License 1.14 · 소스는 CH-J Proprietary Software License 1.14에 따라 제공됩니다. 소스를 공개한다고 해서 라이선스 조항이 바뀌거나 추가 권리가 부여되지는 않습니다.
압축 파일 SHA-256
a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a